GrizzlyGrizzly

Overview

Grizzly is a phishing-detection API. Send it any URL and it classifies whether the page is a deceptive login or credential-harvesting attempt, returning a verdict plus a few human-readable reasons.

It’s built for developers, security integrators, email gateways, and SOC tooling — anywhere you need to check URLs programmatically. The same classification engine powers the Grizzly web app and Chrome extension.

All requests go to https://api.grizzlysec.com. The free tier includes a daily scan quota per key, intended for evaluation and personal use; your current limit is in every response’s X-RateLimit-Limit header. For higher volume, get in touch.

Feed access available on request.

Quickstart

1. Get an API key

Sign in with Google or GitHub, then create an API key from your dashboard. The key is shown once and starts with gk_live_ — store it somewhere safe.

2. Make your first scan

Send a URL to the scan endpoint with your key in the Authorization header:

curl https://api.grizzlysec.com/scan \
  -H "Authorization: Bearer gk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://paypal.com.account-verify.example"}'

3. Read the response

You get back a verdict in classification and an explanation in reasons:

{
  "scan_id": "550e8400-e29b-41d4-a716-446655440000",
  "input_url": "https://paypal.com.account-verify.example",
  "url": "https://paypal.com.account-verify.example",
  "host": "paypal.com.account-verify.example",
  "classification": "phish",
  "reasons": [
    { "code": "domain_squatting", "message": "Domain name closely resembles PayPal's real domain" },
    { "code": "platform_brand_mismatch", "message": "Hosted outside PayPal's infrastructure" }
  ],
  "target_brand": "PayPal",
  "page_intention": "credential_collection",
  "page_description": "Sign-in page requesting a PayPal email and password.",
  "screenshot_url": "https://api.grizzlysec.com/scan/550e8400-e29b-41d4-a716-446655440000/screenshot",
  "screenshot_expires_at": "2026-07-06T15:23:45Z",
  "scanned_at": "2026-06-06T15:23:45Z"
}

Authentication

Grizzly uses bearer tokens. Pass your API key in the Authorization header on every request:

Authorization: Bearer gk_live_...

Keys are issued per user and can be rotated from your dashboard. Treat your key like a password: use it only from server-side code, and never ship it in a browser or mobile app. A missing or invalid key returns 401 (see Errors).

API reference

POST https://api.grizzlysec.com/scan

Submit a URL for classification.

Request body

FieldTypeDescription
urlstring · requiredThe URL to scan. If you omit the scheme, https:// is assumed.

Response

A successful request returns 200 with a JSON object:

{
  "scan_id": "550e8400-e29b-41d4-a716-446655440000",
  "input_url": "https://roblox.com.glogin-secure.example",
  "url": "https://roblox.com.glogin-secure.example",
  "host": "roblox.com.glogin-secure.example",
  "classification": "phish",
  "reasons": [
    { "code": "domain_squatting", "message": "Domain name closely resembles Roblox's real domain" },
    { "code": "dns_ns_suffix_match", "message": "DNS nameservers do not match Roblox's known infrastructure" }
  ],
  "target_brand": "Roblox",
  "page_intention": "credential_collection",
  "page_description": "Login form prompting for a Roblox username and password.",
  "screenshot_url": "https://api.grizzlysec.com/scan/550e8400-e29b-41d4-a716-446655440000/screenshot",
  "screenshot_expires_at": "2026-07-06T15:23:45Z",
  "scanned_at": "2026-06-06T15:23:45Z"
}
FieldTypeDescription
scan_idstringUnique scan ID. Also the path of the shareable result page: grizzlysec.com/scan/{scan_id}.
input_urlstringThe URL you submitted.
urlstringThe URL the verdict applies to. A scan may evaluate multiple pages (e.g. across a redirect chain); Grizzly returns the most severe classification, and url is that page’s URL — not necessarily the final one.
hoststringHostname of the classified URL.
classificationstring · nullThe verdict — see values below.
reasonsobject[]Explanations for the verdict. Each entry is { code, message } — a stable indicator code and a human-readable message. May be empty.
target_brandstring · optionalThe brand being impersonated, when one is identified (e.g. Roblox).
page_intentionstring · optionalEnum code for the page’s apparent goal, when identified (e.g. credential_collection, pii_collection).
page_descriptionstring · optionalA short (≤20-word) factual summary of the page. Present only when the page was fetched and read (browser scans); absent otherwise.
screenshot_urlstring · optionalLink to the page screenshot captured during the scan. Present only when a browser pass ran — scans that settled on the fast HTTP check, and scans that never loaded a page, have no pixels to return. See Screenshots.
screenshot_expires_atstring · optionalISO-8601 UTC timestamp after which screenshot_url stops serving the image. Accompanies screenshot_url whenever it’s present.
scanned_atstringISO-8601 UTC timestamp.

Verdict values

  • phish — confirmed phishing or credential harvesting.
  • suspicious — suspicious signals, but not confirmed.
  • neutral — no phishing signals detected (e.g. a non-login page).
  • valid — a verified, trusted domain.
  • null — the scan ran but couldn’t reach a verdict (see Errors).

Timing

Most scans return within a few seconds. A page that needs a full browser render can take longer; allow 100 seconds on your HTTP client. A scan that runs out of render time still returns 200 with no classification and a reason explaining why, so check reasons rather than retrying on a timer.

Rate limits

Each key has a daily scan quota, reset at 00:00 UTC. The limit itself isn’t fixed in these docs: read it from the headers below. Only successful (2xx) scans count — failed requests don’t consume quota. Every response includes:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 1717891200

X-RateLimit-Reset is a Unix timestamp for the next reset. Exceeding the limit returns 429 with a Retry-After header.

Batch scanning

POST https://api.grizzlysec.com/scan/batch

Scan up to 25 URLs in one call. Same auth, same quota, same per-item shape as POST /scan. Built for the links in one email or document, which usually come in related groups: several tracking links to the same landing page, a button and its fallback text link, a footer full of one sender’s domains.

Every URL is first resolved and checked without a browser. Most links settle there: known platforms, and pages whose content is plainly harmless, get their own verdict and are done.

The URLs that still need a browser render are grouped by the destination host after redirects, so two different tracking links that land on the same site end up in one group. One URL per group is rendered, and the others take its verdict with status: inherited and inherited_from pointing at the rendered one. Grouping is skipped when the destination is the shared root of a multi-tenant platform, where different paths can belong to different owners; those URLs are rendered individually.

The effect is a faster batch, not a cheaper one: each unique URL still costs one scan, whether it was rendered, settled without a browser, or inherited its verdict.

Request body

{ "urls": ["https://example.com/a", "https://example.com/b"] }
FieldTypeDescription
urlsstring[] · required1 to 25 URLs, each at most 2048 characters. Anything outside that is a 422. Exact duplicates are collapsed: results has one item per unique URL, in input order. Only urls is accepted — there is no scan mode or visibility field.

Response

A successful request returns 200 with a JSON object:

{
  "batch_id": "d1c502a1-787f-4ea9-8ab1-47c6a643f7e1",
  "results": [
    {
      "status": "scanned",
      "scan_id": "4a7432e6-17a4-40c5-beac-39098d620023",
      "url": "https://makro-erp-xxxx.kinsta.page/login",
      "input_url": "https://makro-erp-xxxx.kinsta.page/",
      "host": "makro-erp-xxxx.kinsta.page",
      "classification": "suspicious",
      "reasons": [ { "code": "bpa_publisher_mismatch", "message": "Hosted on a public/shared platform" } ],
      "target_brand": "Makro",
      "page_intention": "credential_collection",
      "page_description": "MAKRO sign-in page with username and password fields and a log in button",
      "screenshot_url": "https://api.grizzlysec.com/scan/4a7432e6-17a4-40c5-beac-39098d620023/screenshot",
      "screenshot_expires_at": "2026-10-30T15:50:52Z",
      "scanned_at": "2026-09-30T15:50:52Z"
    },
    {
      "status": "inherited",
      "inherited_from": "https://makro-erp-xxxx.kinsta.page/",
      "scan_id": "a5b36c7a-ed0c-45c2-84ee-92164f4b831a",
      "url": "https://makro-erp-xxxx.kinsta.page/?ref=2",
      "input_url": "https://makro-erp-xxxx.kinsta.page/?ref=2",
      "host": "makro-erp-xxxx.kinsta.page",
      "classification": "suspicious",
      "reasons": [ { "code": "bpa_publisher_mismatch", "message": "Hosted on a public/shared platform" } ],
      "target_brand": "Makro",
      "page_intention": "credential_collection",
      "page_description": "MAKRO sign-in page with username and password fields and a log in button",
      "scanned_at": "2026-09-30T15:50:52Z"
    },
    {
      "status": "timeout",
      "url": "https://slow.example/landing",
      "input_url": "https://slow.example/landing",
      "host": "slow.example",
      "reasons": [ { "code": "batch_timeout", "message": "The batch's time budget ran out before this page finished rendering." } ],
      "scanned_at": "2026-09-30T15:50:52Z"
    }
  ]
}

batch_id is shared by every scan in the call. Each item has every field of a POST /scan response, plus:

FieldMeaning
statusBranch on this. scanned: analysed, own verdict. inherited: verdict taken from another URL in the batch on the same host, named in inherited_from. timeout: the batch’s time budget ran out before this page finished rendering. error: the scan failed.
inherited_fromOnly on inherited items. The batch URL, as you submitted it, whose verdict this item carries.
  • inherited items have their own scan_id and result page, but never a screenshot_url, because they were not rendered themselves.
  • timeout and error items have no classification and no scan_id, and carry a single reason (batch_timeout or scan_error). They are not neutral. Resubmit those URLs, on their own or via POST /scan, to get a verdict.

Quota

Each unique URL in the batch costs one scan from your daily quota, including inherited and timeout items. Only error items are free. The whole batch is checked up front: if you do not have enough quota left for every URL, the call returns 429 and nothing is scanned. X-RateLimit-Remaining on the response already reflects the batch.

Timing

Allow 130 seconds on your HTTP client. URLs that need a browser render share the batch’s time budget, so on a batch with many pages to render some may come back as status: timeout while the rest carry verdicts. Treat timeout items as not yet scanned and resubmit them, on their own or via POST /scan.

Errors

StatusMeaning
401Missing, malformed or revoked API key.
422Empty list, more than 25 URLs, or a URL over 2048 characters.
429Not enough daily quota left for this batch. Retry-After and X-RateLimit-Reset say when it resets.

Screenshots

When a scan runs a full browser pass, Grizzly captures a screenshot of the page as it rendered and returns a screenshot_url alongside the verdict. It’s the visual evidence behind the classification — useful for analyst review, ticket attachments, and audit trails.

GET https://api.grizzlysec.com/scan/{scan_id}/screenshot

Authenticate exactly as you do for scanning. The endpoint responds 302 with a short-lived redirect to the image, so use a client that follows redirects:

curl -L -o screenshot.png \
  https://api.grizzlysec.com/scan/550e8400-e29b-41d4-a716-446655440000/screenshot \
  -H "Authorization: Bearer gk_live_..."

The final response is a image/png of the browser viewport at the moment the page was classified.

Availability

  • Browser scans only. A screenshot exists only when a browser pass actually rendered the page. Scans that settled on the fast HTTP check, and scans that ended in a DNS or TLS failure, return no screenshot_url at all.
  • Owner-only. You can fetch screenshots for your own scans. Someone else’s scan_id returns 404, the same as an unknown one.
  • Available for 30 days from the time of the scan, given by screenshot_expires_at. After that the endpoint returns 410. If you need the image long-term, download and store it on your side before then.
  • Doesn’t consume quota. Fetching a screenshot isn’t a scan and doesn’t count against your daily limit.

Handling the link

Request screenshot_url when you need the image rather than caching the redirect it hands back — the storage URL it points to is signed and expires within minutes, while screenshot_url itself keeps working for the full 30 days. It requires your API key, so it can’t be dropped into an <img> tag in a browser; fetch it server-side and re-serve the bytes.

Screenshot capture is best-effort and runs slightly behind the verdict, so a screenshot_url can briefly 404 immediately after a scan, and a small number of scans never produce one. Treat the image as optional and retry once after a moment before giving up.

Errors

Errors use standard HTTP status codes, and the body has the shape { "detail": ... }.

StatusMeaning
200Success. A scan that runs but can’t reach a verdict also returns 200, with classification: null and an explanation in reasons.
401Unauthorized — missing or invalid API key. {"detail":"unauthorized"}
404Screenshot endpoint only. Either the scan doesn’t exist or isn’t yours ({"detail":"scan_not_found"} — the two are deliberately indistinguishable), or the scan has no screenshot ({"detail":"screenshot_unavailable"}).
410Screenshot endpoint only. The 30-day window has passed. {"detail":"screenshot_expired"}
422Invalid request body (e.g. a missing url). detail lists the validation problems.
429Rate limit exceeded. {"detail":"rate_limit_exceeded"}, with a Retry-After header.
500Internal error. Usually transient — retry the request.
503Screenshot storage temporarily unavailable. {"detail":"storage_unavailable"} — retry.

Terminal scan errors

When a scan completes but can’t classify the page — a DNS failure, TLS error, or timeout — you still get 200 with classification: null and a human-readable reason:

{
  "scan_id": "550e8400-e29b-41d4-a716-446655440000",
  "input_url": "https://does-not-resolve.example",
  "url": "https://does-not-resolve.example",
  "host": "does-not-resolve.example",
  "classification": null,
  "reasons": [
    { "code": "dns_not_resolved", "message": "Domain does not resolve (DNS lookup failed)" }
  ],
  "scanned_at": "2026-06-06T15:23:45Z"
}