Scanify
Menu
URL scannerFile scannerIP checkerPricingCompareAbout
Open scanner →
Home › Developers
REST · JSON · No key needed to try

A link safety API that shows its reasons

Send a URL. Get back where it really goes, how old the domain is, whether its name imitates a brand, and an allow, review or block answer with the reasons behind it. Everything runs on Scanify's own servers, so your users' links are not passed to another scanner.

Get a free key Read the quickstart
GET /api/v1/check?url=
Press "Send request". This box calls the live API from your browser, with no key.

The link filled in is a real look-alike of steamcommunity.com ("rn" in place of "m"). It is parked and for sale, so it is harmless to check.
No request sent yetWithout a key: 5 requests a minute, 50 a day

Quickstart

The base address is https://scanify.to/api/v1. Every answer is JSON. You can make your first request right now, without signing up:

curl "https://scanify.to/api/v1/check?url=steamcornmunity.com"

Example response captured on 10 November 2026. Redirects, certificates and domain age can change.

{
  "url": "https://steamcornmunity.com/",
  "final_url": "https://www.hugedomains.com/domain_profile.cfm?d=steamcornmunity.com",
  "recommendation": "review",
  "risk_score": 25,
  "risk_level": "medium",
  "summary": "Be careful with this link",
  "signals": [
    { "code": "cross_domain_redirect", "weight": 5,
      "detail": "Redirects to a different domain: hugedomains.com" },
    { "code": "lookalike_letters", "weight": 20,
      "detail": "Look-alike letters (\"rn\" for \"m\") used to imitate \"steamcommunity\" in the link you were given" }
  ],
  "redirects": {
    "count": 1,
    "chain": [
      "https://steamcornmunity.com/",
      "https://www.hugedomains.com/domain_profile.cfm?d=steamcornmunity.com"
    ],
    "via_shortener": false,
    "truncated": false
  },
  "domain": {
    "name": "hugedomains.com",
    "host": "hugedomains.com",
    "resolves": true,
    "registered_at": "2003-10-31T19:24:33.000Z",
    "age_days": 8410,
    "registrar": "TurnCommerce, Inc. DBA NameBright.com",
    "abused_tld": false
  },
  "tls": { "valid": true, "matches_host": true, "issuer": "WE1", "issued_days_ago": 23, "expires_in_days": 66 },
  "brand": { "impersonates": "steamcommunity", "method": "letter_swap", "seen_in": "given_link", "custom": false },
  "links": {
    "result": "https://scanify.to/link-expander?q=https%3A%2F%2Fsteamcornmunity.com%2F",
    "screenshot": "https://scanify.to/api/v1/screenshot?url=https%3A%2F%2Fwww.hugedomains.com%2Fdomain_profile.cfm%3Fd%3Dsteamcornmunity.com"
  },
  "checked_at": "2026-11-10T12:20:23.207Z",
  "cached": false
}

Keys

A key lifts the per-IP limits, gives you a monthly allowance and unlocks the batch, message and screenshot endpoints. Sign in at scanify.to/app#/settings and press Generate key. It takes under a minute and needs no card.

Send the key in the Authorization header. X-API-Key works too.

curl "https://scanify.to/api/v1/check?url=example.com" \
  -H "Authorization: Bearer sfy_your_key_here"

Endpoints

EndpointWhat it doesCostKey
GET /checkJudge one link: verdict, signals, redirects, domain, certificate1optional
POST /check/batchJudge up to 50 links in one request1 per linkneeded
POST /extractFind every link in a message and judge each1 per link foundneeded
GET /expandThe redirect chain only. The fastest call1optional
GET /domain/{domain}A domain on its own: age, registrar, certificate, look-alikes1optional
GET /screenshotA picture of the page, taken in the sandbox5needed
GET /usageYour plan, limits and this month's count0optional

A machine-readable description of all of this is at /api/v1/openapi.json (OpenAPI 3.0). Import it into Postman, Insomnia or Bruno, or generate a client from it.

Check a link

GET /api/v1/check?url={link} or POST with a JSON body

Scanify requests the link from its own servers, follows up to 10 redirects reading only the response headers, then checks the place it ends up. The page body is never downloaded and nothing on it runs.

ParameterMeaning
urlThe link. http or https, or a bare domain (https is assumed). URL-encode it in a query string. Required.
brandsYour own brand names, comma separated or a JSON array, up to 10. See Your own brand.
own_domainsUp to 20 domains you own. Custom brand matching skips these hosts and their subdomains. Other risk checks still apply.
freshtrue skips the 10-minute cache. Needs a key.
curl -X POST "https://scanify.to/api/v1/check" \
  -H "Authorization: Bearer $SCANIFY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://bit.ly/3xyz", "brands": ["acmepay"], "own_domains": ["acmepay.com"]}'

Check many links

POST /api/v1/check/batch key needed

Up to 25 links per request on the free plan and 50 on Pro. Duplicates are removed. Results come back in the order you sent them. A link that cannot be checked becomes an entry with an error; the request itself still returns 200.

curl -X POST "https://scanify.to/api/v1/check/batch" \
  -H "Authorization: Bearer $SCANIFY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://example.com", "http://github.com", "not a link"]}'
{
  "count": 3,
  "results": [
    { "url": "https://example.com/", "recommendation": "allow", "risk_score": 0, "...": "..." },
    { "url": "http://github.com/", "final_url": "https://github.com/", "recommendation": "allow", "...": "..." },
    { "url": "not a link", "error": { "code": "invalid_target", "message": "Not a valid URL. Send an http or https link, or a bare domain." } }
  ]
}

Links in a message

POST /api/v1/extract key needed

Send a whole message: an email, a chat message, a support ticket, a comment. Scanify pulls out every link, unwraps the common wrappers that mail systems add (Outlook Safe Links, Google redirect links), checks each one, and tells you which is worst. Up to 20 links and 100,000 characters. A message with no links costs nothing.

curl -X POST "https://scanify.to/api/v1/extract" \
  -H "Authorization: Bearer $SCANIFY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Your parcel is held. Pay the fee at http://paypa1-parcel.top/pay today."}'
{
  "count": 1,
  "urls": ["http://paypa1-parcel.top/pay"],
  "results": [ { "url": "http://paypa1-parcel.top/pay", "recommendation": "block", "...": "..." } ],
  "truncated": false,
  "highest_risk": { "url": "http://paypa1-parcel.top/pay", "risk_score": 63, "recommendation": "block" }
}

Expand a link

GET /api/v1/expand?url={link}

Only the redirect chain: no registration or certificate lookups. Use it to show people where a short link goes, or to store the real destination next to a link.

{
  "url": "http://github.com/",
  "final_url": "https://github.com/",
  "redirected": true,
  "redirects": { "count": 1, "chain": ["http://github.com/", "https://github.com/"], "via_shortener": false, "truncated": false },
  "final_domain": "github.com",
  "signals": [],
  "checked_at": "2026-11-10T12:21:29.173Z",
  "cached": false
}

Check a domain

GET /api/v1/domain/{domain}

Facts about a domain name, with no page requested at all. It answers the questions a sign-up or fraud check asks: when was this domain registered, does it exist, does its certificate match, is the name a copy of someone else's? An email address works too; the part after the @ is used.

{
  "domain": "github.com",
  "host": "github.com",
  "recommendation": "allow",
  "risk_score": 0,
  "risk_level": "safe",
  "signals": [],
  "resolves": true,
  "registered_at": "2007-10-09T18:20:50.000Z",
  "age_days": 6971,
  "registrar": "MarkMonitor Inc.",
  "abused_tld": false,
  "tls": { "valid": true, "matches_host": true, "issuer": "Sectigo Public Server Authentication CA DV E36", "issued_days_ago": 11, "expires_in_days": 78 },
  "brand": null,
  "checked_at": "2026-11-10T12:21:30.351Z",
  "cached": false
}

Screenshot

GET /api/v1/screenshot?url={link} key needed · returns an image

Scanify opens the page in a sandboxed browser on its side and sends back a JPEG, so a moderator can look at a suspicious page without loading it. Options: width (320 to 1920, default 1280), height (240 to 2000, default 800) and full_page=true. A screenshot costs 5 checks. If the page cannot be opened you get a 502 and the 5 checks are returned.

curl "https://scanify.to/api/v1/screenshot?url=https://example.com" \
  -H "Authorization: Bearer $SCANIFY_KEY" -o page.jpg
Scam pages often hide from scanners: they show the real page only to phones on a mobile network, or only once. A blank or "not found" picture does not mean the link is harmless.

Usage

GET /api/v1/usage

Free to call. Metered requests also return rate-limit headers and, when you use a key, monthly quota headers.

{
  "plan": "free",
  "requests": { "used": 18, "limit": 1000, "remaining": 982, "resets_at": "2026-12-01T00:00:00.000Z" },
  "rate_limit": { "per_minute": 30 },
  "batch_size": 25,
  "costs": { "check": 1, "expand": 1, "domain": 1, "screenshot": 5 }
}

Reading the answer

Each signal adds its weight to risk_score, capped at 100. The score maps to a level, and the level to a recommendation:

Scorerisk_levelrecommendationWhat to do with it
0 to 4safeallowNo check fired. Let it through.
5 to 19lowallowOne small note, such as a redirect to another domain. Let it through.
20 to 39mediumreviewSomething is off. Show a warning, or hold it for a person.
40 to 69highblockSeveral strong signals. Hide the link.
70 to 100criticalblockHide the link.

The recommendation is a default. If your community is strict you may want to hold everything at review; if it is relaxed you may only act on block. You can also ignore the score and write your own rule from the signals, for example "hold any link whose domain is under 30 days old".

Other fields worth knowing:

Signals

Build your logic on code. The codes below are stable; new ones may be added. detail is a sentence for people and its wording can change.

codeWeightFires when
new_domain8 to 20The destination's domain was registered under 90 days ago (8), under 30 days (15) or under 7 days (20).
domain_not_resolving25The domain has no DNS record right now.
brand_in_hostname25A well-known brand name appears in the hostname of a domain the brand does not own, as in paypal-verify.example.
lookalike_digits20Digits stand in for letters to spell a known name: paypa1, g00gle.
lookalike_letters20Letter pairs that read as one letter spell a known name: rn for m, vv for w, cl for d.
custom_brand_in_hostname25One of your brands appears in a domain that is not in own_domains.
custom_brand_lookalike20A digit or letter-pair copy of one of your brands.
custom_brand_typo20The domain name is one letter away from one of your brands (added, dropped, changed or swapped).
tls_hostname_mismatch15The certificate was issued for a different hostname.
tls_expired10The certificate has expired.
no_https10The destination is served over plain HTTP.
ip_address_host15The destination is a bare IP address, not a name.
https_downgrade12A redirect steps down from HTTPS to HTTP.
cross_domain_redirect5An ordinary link redirects to a different domain.
shortened_link3A known shortener resolves to another domain. Expected, so the weight is small.
multi_domain_chain5The redirect chain crosses three or more domains.
long_redirect_chain5Four or more redirects.
abused_tld8The ending is on published lists of the most abused top-level domains (.top, .xyz, .cfd, .bond and others).
credential_bait_words8Words such as login, verify, secure or wallet in a hyphenated domain name.
hyphenated_domain5Three or more hyphens in the domain name.

The built-in brand list is short on purpose: the names that are copied most (PayPal, Google, Microsoft, Apple, Amazon, Facebook, Instagram, Netflix, the large US banks, Steam, Discord, Roblox, Coinbase, Binance, MetaMask, WhatsApp, Telegram and a few more). For anything else, send your own names.

Your own brand

Scammers who target your users copy your name, not PayPal's. Pass the names you care about in brands and the domains that are really yours in own_domains, on any check, batch, message or domain request. Scanify then flags a link when its domain:

curl "https://scanify.to/api/v1/domain/acmepay-support.com?brands=acmepay&own_domains=acmepay.com" \
  -H "Authorization: Bearer $SCANIFY_KEY"
{
  "domain": "acmepay-support.com",
  "recommendation": "block",
  "risk_score": 58,
  "risk_level": "high",
  "signals": [
    { "code": "domain_not_resolving", "weight": 25, "detail": "Domain doesn't exist right now (no DNS record), so nothing here can be verified" },
    { "code": "credential_bait_words", "weight": 8, "detail": "Credential-bait words in the domain name" },
    { "code": "custom_brand_in_hostname", "weight": 25, "detail": "Your brand \"acmepay\" appears in the domain name, on a domain that is not yours" }
  ],
  "brand": { "impersonates": "acmepay", "method": "name_in_hostname", "seen_in": "destination", "custom": true },
  "...": "..."
}

Names are compared without spaces, dots or hyphens and must be 3 to 30 characters. If you leave out own_domains, a domain whose name is exactly the brand (acmepay.io) is assumed to be yours. Short or common words make poor brand names here: go or shop would match far too much.

Limits and plans

No key

Free
  • 5 requests a minute
  • 50 requests a day, per IP address
  • Check, expand and domain
  • For trying it out

Free key

$0
  • 1,000 checks a month
  • 30 requests a minute
  • Batches of 25 links
  • Every endpoint, 2 keys

Pro

$20 a month
  • 20,000 checks a month
  • 120 requests a minute
  • Batches of 50 links
  • Every endpoint, 10 keys

Pro is the same Scanify Pro plan that unlocks the website's full scans; the API allowance is part of it. Need more than 20,000 a month? Write to shieldscansupport@gmail.com with your expected volume.

How counting works:

Every response carries the current numbers:

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute on your plan
X-RateLimit-RemainingRequests left in the current minute
X-RateLimit-ResetWhen the minute ends, as a Unix timestamp
X-Quota-Limit, X-Quota-RemainingYour monthly allowance and what is left (with a key)
X-Scanify-Plananonymous, free or pro
Retry-AfterOn a 429 for a rate limit: seconds to wait

Results are cached for 10 minutes, so asking about the same link twice is fast. The API can be called from a browser on any origin (CORS is open), but remember that a key in front-end code is public.

Errors

Errors use the matching HTTP status and always have the same shape:

{
  "error": {
    "code": "non_public_target",
    "message": "That address is private or internal and cannot be checked.",
    "docs": "https://scanify.to/developers"
  }
}
StatuscodeMeaning
400missing_parameterA required field was not sent.
400invalid_targetNot a usable link or domain.
400non_public_targetThe address is private or internal (localhost, 10.x, cloud metadata). Scanify never requests those.
400invalid_jsonThe body is not valid JSON.
400batch_too_largeMore links than your plan takes in one request.
401api_key_requiredThis endpoint needs a key.
401invalid_api_keyThe key is wrong or has been revoked.
413payload_too_largeBody over 256 KB, or text over 100,000 characters.
429rate_limitedToo many requests this minute. Wait Retry-After seconds.
429daily_limit_reachedThe 50-a-day limit for requests without a key.
429quota_exceededThe monthly allowance is used up. resets_at says when it returns.
502screenshot_failedThe page could not be opened in the sandbox.
503busy, screenshots_unavailableTry again in a few seconds.
500internal_errorOur fault. Try again; if it keeps happening, tell us.
Decide in advance what your app does when a check fails or times out. For chat moderation the usual choice is to let the message through: a safety check that is down should not stop people talking.

Recipes

Remove unsafe links in a Discord server

Every message goes to /extract, which finds the links itself. Messages with no links cost nothing.

import { Client, GatewayIntentBits } from 'discord.js';

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
});

client.on('messageCreate', async (msg) => {
  if (msg.author.bot || !msg.content.includes('.')) return;

  const res = await fetch('https://scanify.to/api/v1/extract', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.SCANIFY_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ text: msg.content }),
  }).catch(() => null);
  if (!res || !res.ok) return;                 // if the check fails, let the message through

  const { highest_risk: worst } = await res.json();
  if (worst?.recommendation === 'block') {
    await msg.delete();
    await msg.channel.send(`Removed a link from ${msg.author}: it looks unsafe (${worst.risk_score}/100).`);
  } else if (worst?.recommendation === 'review') {
    await msg.react('⚠️');
  }
});

client.login(process.env.DISCORD_TOKEN);

Check a link a user submits (Node, Express)

async function linkIsAcceptable(url) {
  const res = await fetch('https://scanify.to/api/v1/check', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.SCANIFY_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url, brands: ['acmepay'], own_domains: ['acmepay.com'] }),
    signal: AbortSignal.timeout(20000),
  });
  if (!res.ok) return { ok: true, checked: false };        // your call: fail open or closed
  const r = await res.json();
  return { ok: r.recommendation !== 'block', checked: true, reasons: r.signals.map(s => s.detail), finalUrl: r.final_url };
}

app.post('/profile/website', async (req, res) => {
  const verdict = await linkIsAcceptable(req.body.website);
  if (!verdict.ok) return res.status(422).json({ error: 'That link looks unsafe.', reasons: verdict.reasons });
  // Save the accepted URL here.
  return res.sendStatus(204);
});

Flag sign-ups from brand-new domains (Python)

import os, requests

def email_domain_risk(email: str) -> dict:
    domain = email.rsplit("@", 1)[-1]
    r = requests.get(
        f"https://scanify.to/api/v1/domain/{domain}",
        headers={"Authorization": f"Bearer {os.environ['SCANIFY_KEY']}"},
        timeout=20,
    )
    r.raise_for_status()
    d = r.json()
    return {
        "hold_for_review": d["resolves"] is False or (d["age_days"] is not None and d["age_days"] < 30),
        "age_days": d["age_days"],
        "imitates": (d["brand"] or {}).get("impersonates"),
    }

Check a list of links (Python)

import os, requests

def check_all(urls, size=25):                      # 50 on Pro
    out = []
    for i in range(0, len(urls), size):
        r = requests.post(
            "https://scanify.to/api/v1/check/batch",
            headers={"Authorization": f"Bearer {os.environ['SCANIFY_KEY']}"},
            json={"urls": urls[i:i + size]},
            timeout=60,
        )
        r.raise_for_status()
        out += r.json()["results"]
    return out

for item in check_all(open("links.txt").read().split()):
    if "error" in item:
        print("could not check", item["url"], item["error"]["code"])
    elif item["recommendation"] != "allow":
        print(item["recommendation"], item["risk_score"], item["url"], "->", item["final_url"])

What it cannot do

This API is quick because it looks at where a link goes and what the destination is, not at what the page contains. Know the limits before you rely on it:

The scoring is described in the open at How we score.

Privacy

The full policy is at scanify.to/privacy.

Questions

Do I need a key to try it?
No. The check, expand and domain endpoints work without one at 5 requests a minute and 50 a day per IP address. A free key raises that to 1,000 checks a month.
Is this a wrapper around VirusTotal or Google Safe Browsing?
No. The API runs only Scanify's own checks: redirect tracing, registration data, the certificate, DNS and analysis of the name. The website's full scan does consult antivirus engines, and that is a separate product.
How fast is it?
Response time depends on the destination, DNS and registration service. Cached checks skip those lookups. /expand is faster because it skips the registration and certificate lookups. A slow registry can stretch a first check to several seconds, so set a client timeout of about 20 seconds.
Can I use it in a commercial product?
Yes, on any plan, within the limits above and the terms. Please do not resell raw API access as your own link-checking API.
Will the response format change?
Fields and signal codes in version 1 will not be renamed or removed. New fields and new signal codes may be added, so ignore what you do not recognise. A breaking change would ship as /api/v2.
I found a link it got wrong. Where do I send it?
To shieldscansupport@gmail.com, with the link and what you expected. Misses and false alarms are how the checks get better.

API version 1 · released 10 November 2026