---
title: "Analyze a URL"
method: GET
path: "/api/v1/analyze"
---

# Analyze a URL

`GET /api/v1/analyze`

## Request

- Base URL: `https://phishunt.io`
- URL: `https://phishunt.io/api/v1/analyze`
- Auth: none declared

Passive analysis of any URL. Checks it against phishunt's stored detections
(active feed, prior detections, new-registration feed) and runs live
URL-shape heuristics (brand match, typosquat/homograph distance, TLD abuse,
PaaS hosting, clone intent). The target URL is **never contacted**.
Suspicious unknown domains are queued for full pipeline analysis. The
full URL (path and query) is transmitted and logged, and if the
domain gets queued it is later fetched by the pipeline - pass a bare
domain when it carries tokens or credentials.

**Read `verdict` and `probability` together.** `verdict` is the
evidence ladder (unchanged). `probability` is a calibrated estimate
that the host is malicious, for a typical URL sent to this API (base
rate about 1.3%). Passive estimates are capped low by design: the page
is not fetched, and phishing with no brand in the URL is invisible to
URL analysis. **A verdict of `no_evidence`, or a low probability, is
not a statement that a site is safe.** Use
`GET /api/v1/analyze/deep` for more evidence. See the `Probability`
schema.

Unicode hosts are normalized (NFKC and IDNA), and `query.host` is
returned in the ASCII `xn--` A-label form.

Each passive query is also logged to recalibrate the probability
model: host, path (no query string, no fragment, no userinfo),
scores, verdict and probability. This log is kept for 90 days.

## Query parameters

- `url` string, uri, required

## Response `200`

Passive analysis result.

- AnalyzeResponse: Passive analysis result for a single URL. The target URL is never contacted.
  - `query` object
    - `url` string: The URL as submitted.
    - `host` string: Hostname extracted from the URL. A Unicode host is returned as its punycode A-label (`xn--...`).
    - `normalized_host` string: The Unicode form of the hostname with confusable letters (for example Cyrillic or Greek twins of Latin letters) folded to Latin. It is used for keyword matching only. Do not use it as an identifier or a lookup key: `host` is the punycode A-label (`xn--...`) form.
    - `apex` string: Registrable domain (eTLD+1) of the host.
  - `known` object: What phishunt already knows about this exact URL.
    - `in_active_feed` boolean: true if the URL is currently in phishunt's active suspicious-phishing feed.
    - `previously_active` boolean: true if the URL was previously flagged but is no longer active.
    - `in_new_registration_feed` boolean: true if the apex appeared in the new-domain-registration scan.
    - `record` object, nullable: Stored detection record. Null when there is no match.
      - `company` string: Targeted brand slug.
      - `first_seen` string, date-time
      - `phishunt_score` integer: phishunt's 0-100 detection score.
      - `score_ex_thirdparty` integer, nullable: The same phishunt score recomputed WITHOUT the third-party reputation layer (external feeds, Google Safe Browsing flags and the urlscan verdict flag). It measures what the phishunt detectors found on their own. Null when the stored record does not carry the signals needed to recompute it. The `probability` block uses this value so a feed hit is not counted twice.
      - `verdict` 'critical' | 'high' | 'medium' | 'low' | 'noise', nullable: phishunt's OWN calibrated verdict tier for this stored record, on its own 80/40/31/20 score-threshold scale (backend `analysis/scoring/weights.py` VERDICT_THRESHOLDS). **Not comparable numerically to `live_analysis.url_risk` below** — that is a different field, computed from URL shape alone, on a different 60/40/20 scale. Measured: 49.1% of active rows land on a different tier when the same score is read against the other scale, and every row at "high" here would read as only "medium" on url_risk's scale. Use the top-level `verdict` field for one adjudicated answer instead of comparing these two scales against each other.
      - `top_signals` object: Top 5 stored-detector signals by value (signal name -> numeric value), highest first.
      - `sources` object
        - `google_safebrowsing` boolean
        - `openphish` boolean
        - `phishtank` boolean
        - `tweetfeed` boolean
        - `urlscan` boolean
      - `detail_url` string: Link to the human-readable detail page for this detection.
      - `ai` object, nullable: AI-generated analyst summary derived from stored detector signals. Treat as untrusted free text; may be absent (null).
        - `summary` string
        - `threat_type` 'credential_harvesting' | 'fake_shop' | 'delivery_scam' | 'crypto_scam' | 'tech_support_scam' | 'prize_survey_scam' | 'malware_delivery' | 'parked_or_staging' | 'other'
        - `key_indicators` string[]
        - `confidence` number, float
        - `enriched_at` string, date-time
  - `live_analysis` object: Real-time heuristic scoring of the URL shape itself (no network contact with the target).
    - `allowlisted` boolean: true if the apex matches a known-good allowlist entry.
    - `brand_match` string, nullable: Brand slug the URL appears to target, if any.
    - `kw_score` integer: Raw keyword-classifier score for the matched brand.
    - `impersonation` boolean: true when a matched brand appears only in the path/query, off the host itself (a compromised/unrelated domain) — mirrors path_signals.brand_off_host.
    - `url_signals` object: Layer-1 URL/domain-shape features, each normalized to a 0-1 number (rounded to 2 decimals). Most are 0.0/1.0 flags; a few (kw_score_norm, levenshtein_brand, tld_abuse, domain_length, subdomain_depth, hyphen_count, char_entropy) are continuous.
      - `kw_score_norm` number: Keyword-classifier score normalized to 0-1 (kw_score / 100, capped).
      - `levenshtein_brand` number: Edit-distance-based typosquat suspicion against known brand domains (0 = exact/no close brand, higher = closer near-miss).
      - `idn_homograph` number: 1.0 if the IDN-decoded hostname contains homograph or mixed-script characters, else 0.0.
      - `brand_in_subdomain` number: 1.0 if a brand keyword appears in the subdomain but not the apex label, else 0.0.
      - `brand_in_apex_label` number: 1.0 if the apex label starts with a brand keyword plus extra characters (e.g. netflix520), else 0.0.
      - `combosquat` number: 1.0 if a brand keyword co-occurs with a credential/action token across a hyphen/token boundary (paypal-login), else 0.0.
      - `tld_abuse` number: TLD abuse-proneness score (higher = more commonly abused for phishing).
      - `domain_length` number: Hostname length normalized to 0-1 (length / 30, capped).
      - `subdomain_depth` number: Subdomain depth normalized to 0-1 (labels beyond the apex, capped at 4).
      - `hyphen_count` number: Hyphen count in the hostname normalized to 0-1 (capped at 4).
      - `creds_in_url` number: 1.0 if the URL embeds userinfo credentials (user:pass@host), else 0.0.
      - `nonstandard_port` number: 1.0 if the URL specifies a port other than 80/443, else 0.0.
      - `char_entropy` number: Shannon entropy of the apex label, normalized to 0-1. Informational only — weight 0 since 2026-05-24, never contributes to url_risk_score.
      - `clone_intent` number: 1.0 if the host/path carries an explicit developer clone/tutorial intent token on a dev-deploy host, else 0.0. Bounds (caps) the score rather than adding to it.
      - `paas_host` number: 1.0 if hosted on a PaaS default domain (vercel.app, pages.dev, etc.), else 0.0. Informational; guards the credential-shape booster so no PaaS clone is lifted by it.
      - `leet_brand` number: Informational flag — the brand match required the digit-homoglyph fold (amaz0n -> amazon); never contributes to the score.
    - `path_signals` object: Passive path/query heuristics, 0-1 numbers (0.0/1.0 flags).
      - `brand_off_host` number: 1.0 if a matched brand appears only in the path/query and nowhere in the host — impersonation on a compromised/unrelated domain — else 0.0.
      - `suspicious_path` number: 1.0 if the path/query contains a credential-phishing token (login, verify, secure, ...), else 0.0.
      - `exec_page` number: 1.0 if the path targets a server-side executable page (.php/.aspx/.jsp/.cgi), else 0.0.
    - `url_risk_score` integer: Composite live-analysis risk score.
    - `url_risk` 'minimal' | 'low' | 'medium' | 'high': Tier for url_risk_score above, on THIS field's own 60/40/20 scale (`_url_risk_tier` in analysis_api/service.py) — URL-shape only, computed from the string alone, no DB lookup. **Not comparable numerically to `known.record.verdict` above** — that field uses a different 80/40/31/20 scale over a different (stored, ground-truth) score; see its own description for the measured drift between the two scales. Use the top-level `verdict` field for one adjudicated answer instead of comparing these two scales directly.
    - `why` object[]: Top-5 score contributors, consistent with url_risk_score. Each entry is one feature's contribution to the weighted sum, a passive path/shape boost, or — on a row where a SCORE_CAPS entry bounded the score downward — a synthetic negative-points correction that reconciles the shown total back to url_risk_score.
      - `signal` string: Feature name (or "<feature>_cap" / "score_ceiling" for a cap entry).
      - `value` number: The feature's own 0-1 value (always 1.0 for path_boost/shape_boost/cap entries).
      - `weight` number: The feature's weight in the weighted sum (0.0 for path_boost/shape_boost/cap entries — those are additive/corrective, not part of the sum).
      - `points` number: Points this entry contributes to url_risk_score (negative for a cap entry).
      - `kind` 'weighted' | 'path_boost' | 'shape_boost' | 'cap': weighted = part of the base weighted sum; path_boost/shape_boost = additive passive-path/URL-shape boosts; cap = synthetic reconciliation entry.
  - `history` object: Aggregate history for the URL's apex domain.
    - `apex_prior_detections` integer: Count of prior rows on this scope whose verdict is medium, high or critical. Before 2026-09-28 this counted every row; that old meaning is now `apex_candidates_seen`.
    - `apex_candidates_seen` integer: Count of ALL stored rows on this scope, whatever their verdict (the old meaning of `apex_prior_detections`). A candidate is a host that phishunt looked at, not a confirmed detection.
    - `scope` 'apex' | 'host': What the history is rolled up on. "apex" = the registrable domain. "host" = the exact host only, used when the apex is shared hosting, a PSL private suffix or a trusted platform (one tenant must not inherit the history of another).
    - `apex_first_seen` string, date-time, nullable
    - `apex_last_seen` string, date-time, nullable
    - `brands_targeted` object[]
      - `company` string
      - `count` integer
  - `external_feeds` object: Whether the URL's apex domain is present in external threat feeds, cross-referenced against phishunt's local read-only cache of each feed (the target URL is still never contacted; `passive_only` stays true).
    - `openphish` boolean: true if the apex is present in the OpenPhish feed.
    - `phishtank` boolean: true if the apex is present in the PhishTank feed.
    - `tweetfeed` boolean: true if the apex is present in TweetFeed.
    - `listed` boolean: true if any of the three feeds above is true.
    - `listed_scope` 'host' | 'apex', nullable: How specific the `listed` hit above is. The three booleans above (and `listed`) are looked up by APEX, not by the literal analyzed host — so e.g. any URL on google.com reads `openphish: true` when OpenPhish actually only has a phishing page on some OTHER *.google.com subdomain. "host" = the analyzed host itself appears in a feed; "apex" = only a DIFFERENT host under the same registrable domain does; null = not listed in any feed at all. This does not change `listed` or the three per-feed booleans — only how much weight to put on them.
    - `note` string, nullable: Present (non-null) only when `listed_scope` is "apex" AND the analyzed host is itself allowlisted trusted infrastructure (`live_analysis.allowlisted`). Reconciles what would otherwise read as a contradiction ("this trusted host is listed as phishing"): the feed hit is real, but it is about a different host sharing the apex, not this one.
    - `status` 'ok' | 'stale' | 'missing': Freshness of phishunt's local read-only cache backing the three feeds above (worst of the three): "ok" = loaded recently, "stale" = loaded at least once but past the freshness window, "missing" = never successfully loaded.
    - `feeds_ok` boolean: true iff status is "ok".
  - `verdict` 'phishing' | 'likely_phishing' | 'suspicious' | 'no_evidence' | 'not_assessed': The single adjudicated field — read this one first. Every other block above measures ONE thing on its own scale and never adjudicates against another block (`known.record.verdict` and `live_analysis.url_risk` in particular are NOT comparable to each other — see each field's own description for the measured drift between their two independent scales); `verdict` reconciles all of them into one answer, in strict priority order: (1) the URL could not be evaluated -> "not_assessed"; (2) `live_analysis.allowlisted` -> "no_evidence", ALWAYS, regardless of what any other block says; (3) `known.record.verdict` critical/high -> "phishing"; (4) `known.record.verdict` medium -> "likely_phishing"; (5) otherwise, `live_analysis.url_risk` high/medium/low/minimal -> likely_phishing/suspicious/suspicious/no_evidence. `external_feeds` and `history` never raise the verdict on their own — they only ever add an explanatory phrase to `verdict_basis` (see there). Measured reason: 27 legitimate-brand apexes are in today's PhishTank cache, and 60 of 73 known-benign corpus URLs have SOME apex history. `known.in_active_feed` by itself carries no independent weight either (36 of 73 benign corpus URLs are in the active feed at verdict "low" only because of the clone-hosting score cap) — only `known.record.verdict` (rules 3/4 above) ever adjudicates. **`no_evidence` is not "clean" and not "safe".** It means this API found nothing that points to phishing. A page that is not fetched (passive mode) and has no brand in its URL can be phishing and still read `no_evidence`. Read `probability` beside it, and use `/api/v1/analyze/deep` when you need more evidence.
  - `verdict_confidence` 'high' | 'medium' | 'low': How much weight to put on `verdict`. "high" only for an allowlisted host (rule 2) or a critical/high `known.record` match (rule 3); "low" for "not_assessed" and for the weakest URL-shape-only outcomes.
  - `verdict_basis` string[]: Short human-readable phrases explaining where `verdict` comes from, ordered most to least determinant. Faithful to what actually decided it: a block that did NOT determine the verdict never contributes a phrase — e.g. a "critical" `known.record` match never mentions a low `live_analysis.url_risk_score`, even though both can be true in the same response (that disagreement between the two independent scales is exactly the defect this field exists to resolve).
  - `probability` Probability: A calibrated estimate that the host is malicious, **for a typical URL sent to this API**. The base rate in that population is about 1.3% (`prior`: the measured share of malicious hosts among queried URLs), so most hosts sit near that number until evidence moves them. It sits beside `verdict`, it does not replace it: `verdict`, `verdict_confidence` and `verdict_basis` are the evidence ladder and are unchanged. `interval_80` carries the estimation error of the model. Passive estimates (`coverage: "passive_url_only"`) are capped low by design: the page is not fetched, and phishing with no brand in the URL is invisible to URL analysis. **A low probability, like the verdict `no_evidence`, is not a statement that a site is safe.** Use `GET /api/v1/analyze/deep` for more evidence.
    - `status` 'ok' | 'unavailable': "unavailable" when the estimate could not be computed. The other numeric fields are then null, and `note` says why. The verdict is not affected.
    - `p_malicious` number, nullable: Probability that the host is malicious, 3 decimals.
    - `percent` integer, nullable: `p_malicious` as a whole percent.
    - `interval_80` number[], nullable: 80% interval [low, high] for `p_malicious`. It carries the estimation error of the model, not the chance of a wrong verdict.
    - `band` 'very_unlikely' | 'unlikely' | 'uncertain' | 'likely' | 'very_likely' | 'null', nullable: Plain-word band of `p_malicious`: very_unlikely (under 5%), unlikely (5% to 30%), uncertain (30% to 70%), likely (70% to 95%), very_likely (over 95%).
    - `relative_risk` number, nullable: `p_malicious` divided by `prior`. 9.5 means the host is 9.5 times more likely to be malicious than a typical queried URL.
    - `coverage` 'passive_url_only' | 'known_record' | 'active_no_render' | 'active_rendered' | 'null', nullable: What evidence the estimate is built on. passive_url_only = URL shape and feed cross-reference only, the page was not fetched. known_record = a stored phishunt record for the host exists, the page was not fetched now. active_no_render = an active analysis without page render. active_rendered = an active analysis with page render.
    - `prior` number, nullable: Base rate used by the model: the measured share of malicious hosts among the URLs queried on this API (about 0.013).
    - `evidence` object[]: Evidence classes that moved the estimate, largest effect first.
      - `class` 'url_shape' | 'external_feed' | 'trusted_zone' | 'record_score' | 'active_score' | 'active_score_rendered' | 'gsb_flag': The evidence class.
      - `value` union: The observed value for this class (a number or a label).
        - number
        - string
      - `llr` number: Log-likelihood ratio (natural log) this class adds to the log-odds. Positive = toward malicious, negative = toward benign.
      - `direction` 'toward_malicious' | 'toward_benign' | 'neutral'
    - `not_evaluated` string[]: Evidence classes that apply to this coverage but could not be evaluated for this URL. They add nothing to the estimate.
    - `model_version` string, nullable
    - `note` string: Plain-language caveat for this coverage. Read it before you trust the number.
  - `action` object
    - `queued_for_analysis` boolean: true if this URL was queued for full pipeline analysis as a result of this request.
    - `reason` 'disabled' | 'ip_literal' | 'invalid_port' | 'invalid_host' | 'reserved_domain' | 'allowlisted' | 'reputable_host' | 'no_brand_match' | 'already_known' | 'brand_match_unknown_domain' | 'already_tracked' | 'apex_capped' | 'queue_error' | 'fp_removed' | 'queue_capped' | 'deep_analyzed', nullable: Why `queued_for_analysis` has its value. Set whenever queueing was skipped ("disabled"/"ip_literal"/ "invalid_port"/"invalid_host"/"reserved_domain"/ "allowlisted"/"reputable_host"/"no_brand_match"/ "already_known" all mean this endpoint deliberately chose not to queue; "ip_literal" = the host is an IP address, "invalid_port" = the URL carries a port that is not valid, "invalid_host" = the host failed the queue-side host check, "reserved_domain" = example.com and other reserved names), or when a genuinely new lead's enqueue attempt itself did not succeed: "already_tracked" (a plain duplicate — this domain is already pending/tracked, not a capacity signal), "apex_capped" (this apex already has the maximum pending rows), "queue_error" (the enqueue attempt failed at the DB level), "fp_removed" (this host was suppressed as a removed false positive, so it is not requeued), or "queue_capped" (fallback for any other outcome). "brand_match_unknown_domain" is the only reason paired with `queued_for_analysis: true`. "already_tracked"/"apex_capped"/"queue_error" used to be collapsed into a single "queue_capped" — split out because a plain duplicate or a DB error is not the same fact as capacity actually being full. "deep_analyzed" is emitted only by /api/v1/analyze/deep (whose response schema inherits this enum): the deep run IS the full analysis, so nothing is queued; the passive endpoint never emits it.
  - `meta` object
    - `analyzed_at` string, date-time
    - `passive_only` boolean: Always true — the target URL is never contacted.
    - `note` string
    - `docs` string
    - `license` string: Always "CC0-1.0".

## Other responses

- `400`: Bad request — missing or invalid `url`. `error` is the exact message raised by the service's validator, e.g. "url is required", "url has no host", "host must contain a dot (bare hostnames/IPs not supported)", "unsupported scheme '<scheme>' (only http/https)", or "url exceeds max length of 2048". Hosts that cannot exist in DNS are also rejected: "host exceeds the 253 character DNS limit", "host has a label over the 63 character DNS limit", "host has an empty label", "host is not a valid internationalized domain name", and "host contains invalid characters".
- `429`: Rate limited (more than 20 requests in 10 seconds from one IP). Retry after 10 seconds.

---

[API](https://skmtc.dev/phishunt/apis/phishunt-api.md) · [All operations](https://skmtc.dev/phishunt/apis/phishunt-api/llms.txt) · [OpenAPI document](https://skmtc.dev/phishunt/apis/phishunt-api/revisions/0e07c255f282?raw)
