Every app that lets users paste a link has the same problem. Someone will eventually paste a phishing link, and your app will render it as a clickable, trustworthy-looking element inside your UI.
The volume is high. The Anti-Phishing Working Group counted 971,181 phishing attacks in the first quarter of 2026, up 13.8% from the quarter before (APWG Phishing Activity Trends Report, Q1 2026, May 2026). Most of those attacks end in a URL.
This post builds a small link checker you can drop into a chat app, a comment system, a CRM or an AI agent. It takes any URL and returns allow, warn or block, plus the reasons. It uses three API calls and a few checks that cost nothing.
What the checker does
A phishing link usually has three traits you can test for:
- It hides where it goes. Short links and redirect chains mask the real destination.
- It is already known. Many phishing URLs get reported to threat databases within hours.
- It is new. Attackers register a lookalike domain, run a campaign for a few days and move on.
So the pipeline is:
input URL
│
▼
1. Expand redirects ──► final URL
│
├──► 2. Threat lookup (known malicious?) ─┐
│ ├──► score ──► allow / warn / block
└──► 3. Domain age (registered last month?) ─┘
+ local checks (https, raw IP, punycode)
Steps 2 and 3 are independent, so they run in parallel.
The order matters. If you check the short link itself, you are checking bit.ly, which is always clean. Every check has to run on the final destination.
The APIs
All three calls go through ApyHub with one API key:
| Step | API | What it returns |
|---|---|---|
| Expand | Resolve Short URL API | Final URL, full redirect chain, a truncated flag |
| Threat lookup | Generate Link Preview API | Threat type if the URL is in a malicious-URL database, page metadata if it is clean |
| Domain age | Domain Age API | WHOIS creation date and age in days |
The Link Preview API is doing double duty. With secure_mode: true it checks the URL against a malicious-URL database before it fetches anything. A flagged URL comes back like this:
{
"data": {
"url": "http://example-malicious-site.com",
"threat": "malware",
"reported_malicious": true
}
}
A clean URL comes back with the page title, description and images, so the same call gives you a safe preview card to show your users.
Setup
mkdir link-checker && cd link-checker
npm init -y
npm i tldts
export APY_TOKEN=your_token_here
tldts extracts the registrable domain from a hostname. You want the age of acme.co.uk, and a naive split on dots would give you co.uk.
You need Node 18 or later for the built-in fetch.
The code
Save this as check-link.mjs:
// check-link.mjs
// Usage: APY_TOKEN=your_token node check-link.mjs "https://bit.ly/example"
import { getDomain } from "tldts";
const API = "https://api.eu.apyhub.com";
const TOKEN = process.env.APY_TOKEN;
async function call(path, { method = "GET", body } = {}) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
"apy-token": TOKEN,
...(body && { "Content-Type": "application/json" }),
},
body: body && JSON.stringify(body),
});
if (!res.ok) throw new Error(`${path} returned ${res.status}`);
return res.json();
}
// Step 1: follow every redirect to the real destination
const unshorten = (url) =>
call("/dosvak/unshorten-url", {
method: "POST",
body: { url, max_hops: 15, timeout_s: 8 },
});
// Step 2: check the destination against a malicious-URL database
const scan = async (url) => {
const { data } = await call("/apyhub/generate-link-preview", {
method: "POST",
body: { url, secure_mode: true },
});
return data;
};
// Step 3: how old is the registrable domain?
// WHOIS data is patchy, so a failed lookup counts as "unknown", not as a crash
const domainAge = (domain) =>
domain
? call(`/dosvak/domain-age?domain=${encodeURIComponent(domain)}`).catch(
() => ({ age_days: null })
)
: Promise.resolve({ age_days: null });
// Free checks that need no API call
function localSignals(finalUrl) {
const { protocol, hostname } = new URL(finalUrl);
const signals = [];
if (protocol !== "https:") signals.push(["no_https", 1]);
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) signals.push(["ip_host", 2]);
if (hostname.split(".").some((part) => part.startsWith("xn--")))
signals.push(["punycode_host", 2]);
return signals;
}
export async function checkLink(input) {
const chain = await unshorten(input);
const finalUrl = chain.resolved;
const domain = getDomain(finalUrl); // "login.acme.co.uk" -> "acme.co.uk"
// Steps 2 and 3 are independent, so run them in parallel
const [preview, age] = await Promise.all([scan(finalUrl), domainAge(domain)]);
if (preview.reported_malicious) {
return { verdict: "block", finalUrl, reasons: [`threat:${preview.threat}`] };
}
const signals = localSignals(finalUrl);
if (chain.truncated) signals.push(["redirect_chain_truncated", 2]);
if (chain.hops.length > 4) signals.push(["long_redirect_chain", 1]);
if (age.age_days == null) signals.push(["domain_age_unknown", 1]);
else if (age.age_days < 30) signals.push(["domain_under_30_days", 3]);
else if (age.age_days < 180) signals.push(["domain_under_6_months", 1]);
const score = signals.reduce((sum, [, weight]) => sum + weight, 0);
return {
verdict: score >= 3 ? "warn" : "allow",
score,
finalUrl,
domain,
domainAgeDays: age.age_days,
title: preview.title ?? null,
reasons: signals.map(([name]) => name),
};
}
if (process.argv[2]) {
console.log(JSON.stringify(await checkLink(process.argv[2]), null, 2));
}
Run it:
node check-link.mjs "https://bit.ly/example"
What the output looks like
A short link that lands on a nine-day-old domain over plain HTTP:
{
"verdict": "warn",
"score": 4,
"finalUrl": "http://secure-acme-login.co.uk/verify",
"domain": "secure-acme-login.co.uk",
"domainAgeDays": 9,
"title": "Sign in",
"reasons": ["no_https", "domain_under_30_days"]
}
A link that is already in the threat database:
{
"verdict": "block",
"finalUrl": "https://evil.example/",
"reasons": ["threat:malware"]
}
A normal link:
{
"verdict": "allow",
"score": 0,
"finalUrl": "https://github.com/",
"domain": "github.com",
"domainAgeDays": 6500,
"title": "GitHub",
"reasons": []
}
The reasons array is the useful part. Show it to your moderators, log it, or turn it into the warning text users see ("This link goes to a site registered 9 days ago").
Why the scoring works this way
The weights are a starting point. Tune them against your own traffic. A few decisions are worth explaining.
A threat-database hit blocks outright. This is the one signal with very few false positives, so it skips the score.
Domain age warns and never blocks. New domains are the strongest signal for campaigns that no database has seen yet. They are also every startup that launched last month. A warning label ("new site, check before you log in") handles both cases.
Unknown age is a mild signal. Some country-code TLDs do not publish WHOIS creation dates. Treating null as "suspicious" would flag half of some regions. Treating it as "safe" would give attackers an easy gap. A weight of 1 splits the difference.
Raw IPs and punycode add weight. Legitimate sites rarely send users to http://185.x.x.x/login. Punycode (xn--) hostnames are how lookalike characters like a Cyrillic "а" get into a domain. Both are free to check.
Making it cheaper
Each check costs three API calls. Three changes cut that a lot in production:
Skip the expand step for direct links. Only call the unshortener when the hostname is a known shortener or when the link came from an untrusted source.
const SHORTENERS = new Set(["bit.ly", "t.co", "tinyurl.com", "ow.ly", "is.gd", "buff.ly"]);
const needsExpand = (url) => SHORTENERS.has(new URL(url).hostname);
Cache domain age by domain. A domain's creation date does not change. Cache it for a day and your hundredth link to github.com costs nothing.
Check on submit, then store the verdict. Run the check once when a user posts the link, save the result with the message, and render from the stored verdict. Re-checking on every page view multiplies your costs by your read traffic.
What this does not catch
Be clear with yourself about the gaps:
- Lookalikes on old domains. A compromised WordPress site from 2014 passes the age check. The threat database is your only defense there, and it lags new campaigns.
- Clean first hops. Some kits show a harmless page to scanners and a login form to real visitors. No URL-level check sees that.
- Messages without links. Phone-number scams, fake invoices with bank details and voice phishing never touch this pipeline.
Treat the checker as one layer. It removes the obvious cases before a person has to judge them. It does not replace user reports, rate limits on new accounts or a way to pull a message after the fact.
Using it from an AI agent
AI agents now read inboxes, open links and fill in forms on a user's behalf. An agent that follows a phishing link does it faster and with less hesitation than a person.
The same three checks work as a gate before an agent acts on any URL. Every endpoint used here is available through ApyHub MCP, so an agent can discover, evaluate and call the Resolve Short URL API, the Generate Link Preview API and the Domain Age API directly, without a hand-written wrapper or tool definition. A system prompt line such as "check every URL with the link tools before opening it, and stop on any threat result" is enough to wire it in.
Going further
If links are only part of your abuse problem, a few related checks slot into the same pattern:
- Domain WHOIS Lookup API for the registrar and full registration record when a domain gets a warning.
- Temporary and Disposable Emails API to stop throwaway accounts that post phishing links in the first place.
- Identity & Fraud Verification API for a single risk score across email, IP and phone at signup.
FAQ
How do I check if a link is safe before opening it?
Expand it to the final URL first, then check that URL against a threat database and look at how old its domain is. The Resolve Short URL API, Generate Link Preview API in secure mode and Domain Age API cover those three steps.
Is a new domain always a phishing domain?
No. New domains carry more risk, and most legitimate sites were new once. That is why the checker warns on domain age and blocks only on a threat-database match.
Does my server visit the phishing site when I expand the link?
The redirect-following happens on the API side, so your own servers never connect to the suspicious host.
How can I test these APIs before writing code?
Run each one from its page in the ApyHub catalog, send the curl request from your terminal, or open the request in Voiden, the open-source API client, add your API key and run it. The free plan works for testing: it allows 5 calls a day, which is enough for one full link check plus a couple of single calls.
Can I run this on every chat message?
Yes, if you check once on submit and cache domain age. See "Making it cheaper" above.
About ApyHub
ApyHub is a curated API catalog and trusted operational layer for developers and AI agents. The catalog offers over 1,500 endpoints and capabilities, and it keeps growing. Every API is verified before listing and carries machine-readable certification for GDPR, SOC 2 and ISO 27001. One subscription covers the whole catalog, with usage measured in atoms, a per-call unit that reflects the compute work each request does. Every endpoint is MCP-ready by default through ApyHub MCP. ApyHub is headquartered in Amsterdam, with offices in the Netherlands, Greece and India, and serves 65,000+ monthly developer workspaces. The free plan needs no credit card, and API providers can publish to the catalog through the provider program.
Top comments (0)