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"const res = await fetch(
'https://scanify.to/api/v1/check?url=' + encodeURIComponent('steamcornmunity.com'),
{ headers: process.env.SCANIFY_KEY ? { Authorization: `Bearer ${process.env.SCANIFY_KEY}` } : {} }
);
const result = await res.json();
if (result.recommendation === 'block') {
// hide the link, hold the message, warn the user
}import os, requests
r = requests.get(
"https://scanify.to/api/v1/check",
params={"url": "steamcornmunity.com"},
headers={"Authorization": f"Bearer {os.environ['SCANIFY_KEY']}"} if os.getenv("SCANIFY_KEY") else {},
timeout=20,
)
r.raise_for_status()
result = r.json()
print(result["recommendation"], result["risk_score"], [s["code"] for s in result["signals"]])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"- A key is shown once, when you create it. Scanify does not keep a copy, so store it in your secrets manager or an environment variable.
- Keep keys on a server. A key placed in a web page or a mobile app can be read by anyone who looks.
- Revoke a key from the same page and it stops working at once. If a key leaks and you are not sure which one, Revoke all keys ends every key your account has made.
Endpoints
| Endpoint | What it does | Cost | Key |
|---|---|---|---|
GET /check | Judge one link: verdict, signals, redirects, domain, certificate | 1 | optional |
POST /check/batch | Judge up to 50 links in one request | 1 per link | needed |
POST /extract | Find every link in a message and judge each | 1 per link found | needed |
GET /expand | The redirect chain only. The fastest call | 1 | optional |
GET /domain/{domain} | A domain on its own: age, registrar, certificate, look-alikes | 1 | optional |
GET /screenshot | A picture of the page, taken in the sandbox | 5 | needed |
GET /usage | Your plan, limits and this month's count | 0 | optional |
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
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.
| Parameter | Meaning |
|---|---|
url | The link. http or https, or a bare domain (https is assumed). URL-encode it in a query string. Required. |
brands | Your own brand names, comma separated or a JSON array, up to 10. See Your own brand. |
own_domains | Up to 20 domains you own. Custom brand matching skips these hosts and their subdomains. Other risk checks still apply. |
fresh | true 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
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
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
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
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
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.jpgUsage
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:
| Score | risk_level | recommendation | What to do with it |
|---|---|---|---|
| 0 to 4 | safe | allow | No check fired. Let it through. |
| 5 to 19 | low | allow | One small note, such as a redirect to another domain. Let it through. |
| 20 to 39 | medium | review | Something is off. Show a warning, or hold it for a person. |
| 40 to 69 | high | block | Several strong signals. Hide the link. |
| 70 to 100 | critical | block | Hide 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:
final_urlandredirects.chain: where the link really goes.truncatedis true if the chain was cut off at 10 hops or 5 seconds.domaindescribes the destination, not the link you sent.age_daysandregistrararenullwhen the registry publishes no data, which is common for some country domains.domain.resolvesisfalsewhen the name has no DNS record. Many scam domains are taken down within days, so a dead link in an old message is itself a signal.brandsays what the name imitates and how.seen_inisgiven_linkwhen the copy was in the address you sent and it redirected somewhere else.links.resultopens the public link expander for the submitted URL. The public tool runs its own check without your custom brand list.
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.
| code | Weight | Fires when |
|---|---|---|
new_domain | 8 to 20 | The destination's domain was registered under 90 days ago (8), under 30 days (15) or under 7 days (20). |
domain_not_resolving | 25 | The domain has no DNS record right now. |
brand_in_hostname | 25 | A well-known brand name appears in the hostname of a domain the brand does not own, as in paypal-verify.example. |
lookalike_digits | 20 | Digits stand in for letters to spell a known name: paypa1, g00gle. |
lookalike_letters | 20 | Letter pairs that read as one letter spell a known name: rn for m, vv for w, cl for d. |
custom_brand_in_hostname | 25 | One of your brands appears in a domain that is not in own_domains. |
custom_brand_lookalike | 20 | A digit or letter-pair copy of one of your brands. |
custom_brand_typo | 20 | The domain name is one letter away from one of your brands (added, dropped, changed or swapped). |
tls_hostname_mismatch | 15 | The certificate was issued for a different hostname. |
tls_expired | 10 | The certificate has expired. |
no_https | 10 | The destination is served over plain HTTP. |
ip_address_host | 15 | The destination is a bare IP address, not a name. |
https_downgrade | 12 | A redirect steps down from HTTPS to HTTP. |
cross_domain_redirect | 5 | An ordinary link redirects to a different domain. |
shortened_link | 3 | A known shortener resolves to another domain. Expected, so the weight is small. |
multi_domain_chain | 5 | The redirect chain crosses three or more domains. |
long_redirect_chain | 5 | Four or more redirects. |
abused_tld | 8 | The ending is on published lists of the most abused top-level domains (.top, .xyz, .cfd, .bond and others). |
credential_bait_words | 8 | Words such as login, verify, secure or wallet in a hyphenated domain name. |
hyphenated_domain | 5 | Three 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:
- contains the name on a domain you do not own:
acmepay-support.com,acmepay.login-help.net - spells it with digits or look-alike letter pairs:
acmepa1.com,acrnepay.com - is one letter away from it:
acmepai.com,amcepay.com,acmeepay.com
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
- 5 requests a minute
- 50 requests a day, per IP address
- Check, expand and domain
- For trying it out
Free key
- 1,000 checks a month
- 30 requests a minute
- Batches of 25 links
- Every endpoint, 2 keys
Pro
- 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:
- One checked link or domain is 1 check, whether or not the answer came from the cache. A screenshot is 5.
- A request that is refused (a malformed link, a private address) is not counted. A batch reserves one check for each distinct, nonempty link, including entries that return errors.
- The month runs on UTC and resets on the 1st.
Every response carries the current numbers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute on your plan |
X-RateLimit-Remaining | Requests left in the current minute |
X-RateLimit-Reset | When the minute ends, as a Unix timestamp |
X-Quota-Limit, X-Quota-Remaining | Your monthly allowance and what is left (with a key) |
X-Scanify-Plan | anonymous, free or pro |
Retry-After | On 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"
}
}| Status | code | Meaning |
|---|---|---|
| 400 | missing_parameter | A required field was not sent. |
| 400 | invalid_target | Not a usable link or domain. |
| 400 | non_public_target | The address is private or internal (localhost, 10.x, cloud metadata). Scanify never requests those. |
| 400 | invalid_json | The body is not valid JSON. |
| 400 | batch_too_large | More links than your plan takes in one request. |
| 401 | api_key_required | This endpoint needs a key. |
| 401 | invalid_api_key | The key is wrong or has been revoked. |
| 413 | payload_too_large | Body over 256 KB, or text over 100,000 characters. |
| 429 | rate_limited | Too many requests this minute. Wait Retry-After seconds. |
| 429 | daily_limit_reached | The 50-a-day limit for requests without a key. |
| 429 | quota_exceeded | The monthly allowance is used up. resets_at says when it returns. |
| 502 | screenshot_failed | The page could not be opened in the sandbox. |
| 503 | busy, screenshots_unavailable | Try again in a few seconds. |
| 500 | internal_error | Our fault. Try again; if it keeps happening, tell us. |
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:
- It is not an antivirus. It does not download pages or files and does not detect malware. For that, use the full scan on scanify.to.
- An old, trusted domain passes. A phishing page hosted on a hacked ten-year-old site, or on a big platform (a form builder, a file host, a page builder), shows none of these signals.
- Some scam pages hide. They show the real page only to certain phones, networks or countries. Scanify sees what its own servers are shown.
allowis not a guarantee. It means the available signals put the link below the review threshold. A malicious page on an established domain can still score low.- New legitimate sites can be flagged. A shop that registered its domain last week will get
new_domain. A domain under seven days old gets 20 points and reachesreviewon age alone.
The scoring is described in the open at How we score.
Privacy
- The links you send are not published, not sold and not sent to any outside scanner.
- To read a registration date Scanify asks the public registry service (RDAP) about the domain name only, never the full link.
- Check results are reused for 10 minutes. Expired entries are cleared from memory on the next minute sweep. Domain names appear in server logs. A full link is logged only when something fails (a refused redirect, a failed screenshot). Message text is never logged.
- For each account Scanify stores a count of this month's requests and the name, creation date and last-used time of each key, plus a short hint (its first and last few characters) so you can tell keys apart. The key itself is not stored.
The full policy is at scanify.to/privacy.
Questions
/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./api/v2.API version 1 · released 10 November 2026