← zerophish

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:

ScopeGrants
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.

POST /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

NameInTypeRequiredDescription
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

202 Scan enqueued. Poll `GET /api/scan/{job_id}`.
{
  "cached": false,
  "deep": false,
  "job_id": 918273
}
200 A recent cached result was returned (no new scan started).
{
  "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"
}
400 `url` was missing or empty.
{
  "error": "url is required"
}
403 API key lacks the `scan` scope.
{
  "error": "insufficient_scope",
  "required": "scan"
}
422 The scan job could not be enqueued.
{
  "error": "Failed to start scan",
  "reason": "..."
}
429 Daily request limit exceeded.
{
  "error": "Daily request limit exceeded"
}

Try it

POST /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

NameInTypeRequiredDescription
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

202 Batch accepted. Poll `GET /api/batches/{batch_id}`; each `accepted` entry also has a `job_id`.
{
  "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"
    }
  ]
}
400 `urls` was missing, not an array, empty, or larger than 50.
{
  "error": "too many urls",
  "max": 50
}
403 Missing/invalid API key, or the key lacks the `scan` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 Aggregate progress + completed results.
{
  "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
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No batch with that id in your organisation.
{
  "error": "Batch not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 Scan completed — the prediction object is returned.
{
  "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"
  }
}
202 Still running or retrying.
{
  "status": "in_progress"
}
400 Job id was not an integer.
{
  "error": "Invalid job ID"
}
404 No job with that id.
{
  "error": "Job not found"
}
422 The scan failed and was discarded.
{
  "error": "fetch failed: timeout"
}

Try it

POST /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

NameInTypeRequiredDescription
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

202 Fresh scan enqueued. Poll `GET /api/scan/{job_id}`.
{
  "cached": false,
  "deep": false,
  "job_id": 918299
}
400 Job id was not an integer.
{
  "error": "Invalid job ID"
}
403 Missing/invalid API key, or the key lacks the `scan` scope.
{
  "error": "authentication_required"
}
404 No scan with that id in your organisation.
{
  "error": "Scan not found"
}
429 Daily request limit exceeded.
{
  "error": "Daily request limit exceeded"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 Chronological scan history for the URL (newest first).
{
  "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/"
}
400 Job id was not an integer.
{
  "error": "Invalid job ID"
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No scan with that id in your organisation.
{
  "error": "Scan not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

POST /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

NameInTypeRequiredDescription
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

200 Updated tag list for the scan.
{
  "id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
  "tags": [
    "credential-harvesting"
  ]
}
400 `tag` was missing or blank.
{
  "error": "tag is required"
}
403 Missing/invalid API key, or the key lacks the `scan` scope.
{
  "error": "authentication_required"
}
404 No such scan in your organisation.
{
  "error": "prediction not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

DELETE /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

NameInTypeRequiredDescription
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

200 Updated tag list for the scan.
{
  "id": "b3b1f0e2-2c4a-4f9d-9d2e-1a2b3c4d5e6f",
  "tags": []
}
403 Missing/invalid API key, or the key lacks the `scan` scope.
{
  "error": "authentication_required"
}
404 No such scan in your organisation.
{
  "error": "prediction not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 The graph payload. `nodes` carry `id`, `type`, `label`, a `pivot` hint and type-specific annotations; `edges` carry `source`, `target` and a relationship `label`.
{
  "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"
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No such scan in your organisation.
{
  "error": "prediction not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A Sigma rule as `application/yaml`.
"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"
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No such scan in your organisation.
{
  "error": "prediction not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 The HAR archive as `application/json` (Content-Disposition: attachment). With `?format=netlog`, a compact network-log object instead.
{
  "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/"
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No such scan in your organisation, or the scan captured no HAR.
{
  "error": "no HAR captured for this scan"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 The scan's intercepted files. `count` is the number of files.
{
  "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"
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No such scan in your organisation.
{
  "error": "prediction not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 Your organisation's campaigns. `count` is this page; `total` is the full count.
{
  "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
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 The campaign and a page of its member scans. `member_count` is the full membership.
{
  "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
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No such campaign in your organisation.
{
  "error": "campaign not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 Known URL — cached prediction returned, with `enrichment` + `prevalence` + `dns_history`.
{
  "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"
}
200 Unknown URL — never scanned by your org.
{
  "status": "unknown"
}
400 `url` query param missing.
{
  "error": "url_required"
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

POST /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

NameInTypeRequiredDescription
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

200 Per-URL results keyed by the input string. `count` is the number of distinct URLs resolved.
{
  "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"
    }
  }
}
400 `urls` was missing, not an array, empty, or larger than 100.
{
  "error": "too_many_urls",
  "max": 100
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 Known public URL.
{
  "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"
}
200 Unknown URL.
{
  "status": "unknown"
}
400 `url` query param missing.
{
  "error": "url_required"
}
429 Rate limited (60/day per IP).
{
  "error": "rate_limited",
  "limit": 60
}

Try it

GET /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

NameInTypeRequiredDescription
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

200 A STIX 2.1 bundle.
{
  "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"
}
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

200 Current usage for the key.
{
  "date": "2026-06-13",
  "scan": {
    "limit": 1000,
    "remaining": 958,
    "used": 42
  }
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

200 The current platform health snapshot.
{
  "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

GET /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

NameInTypeRequiredDescription
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

200 A CSV document (text/csv) with a header row.
"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"
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A MISP Event object.
{
  "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"
  }
}
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A text/plain document with one URL per line.
"https://login.examplebank.com.verify.example/\nhttps://secure-paypa1.example/signin"
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A text/plain BIND RPZ master zone with one `<host> CNAME .` record per distinct phishing host.
"$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 ."
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A Chrome/Edge Enterprise URLBlocklist policy JSON document, one host per array entry.
{
  "URLBlocklist": [
    "login.examplebank.com.verify.example",
    "secure-paypa1.example"
  ]
}
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A proxy auto-config (application/x-ns-proxy-autoconfig) file defining FindProxyForURL.
"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"
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 An RSS 2.0 (application/rss+xml) document.
"<?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>"
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 An Atom 1.0 (application/atom+xml) document.
"<?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>"
304 The feed is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

200 A TAXII Discovery object.
{
  "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"
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

200 A TAXII API Root object.
{
  "description": "Confirmed-phishing threat intelligence.",
  "max_content_length": 104857600,
  "title": "ZerophishEx API Root",
  "versions": [
    "application/taxii+json;version=2.1"
  ]
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

200 A TAXII Collections object.
{
  "collections": [
    {
      "can_read": true,
      "can_write": false,
      "id": "8c1f…org-uuid",
      "media_types": [
        "application/stix+json;version=2.1"
      ],
      "title": "Confirmed phishing"
    }
  ]
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A TAXII Collection object.
{
  "can_read": true,
  "can_write": false,
  "id": "8c1f…org-uuid",
  "media_types": [
    "application/stix+json;version=2.1"
  ],
  "title": "Confirmed phishing"
}
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No such collection in your organisation.
{
  "http_status": "404",
  "title": "Collection not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.

GET /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

NameInTypeRequiredDescription
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

200 A TAXII envelope of STIX 2.1 objects. `more` is true when another page is available; `next` is the cursor to fetch it.
{
  "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"
    }
  ]
}
304 The collection is unchanged since the `ETag` you sent in `If-None-Match`. No body.
""
403 Missing/invalid API key, or the key lacks the `read` scope.
{
  "error": "authentication_required"
}
404 No such collection in your organisation.
{
  "http_status": "404",
  "title": "Collection not found"
}

Try it

Needs an API key (paste one above). Its workspace is used automatically as the organisation — nothing else to set.