Documentation
Complete API reference, CLI commands, and MCP server setup.
On this page
Overview
Send a URL, get structured signals back. You decide what to do with them.
Unphurl analyses URLs across seven dimensions: redirect behaviour, brand impersonation, domain intelligence (age, registrar, expiration, status codes, nameservers via RDAP), SSL/TLS validity, parked domain detection, URL structural analysis (length, path depth, subdomain count, entropy), and DNS enrichment (MX records).
It returns raw signals, a configurable score with 23 weights, and a full breakdown of how that score was calculated. No verdicts, no "safe/unsafe" labels. Your AI agent sets its own threshold.
Base URL
https://api.unphurl.com/v1 Authentication
Every request to check and account endpoints requires an API key via bearer token.
Authorization: Bearer uph_your_key_here
API keys are issued on signup and shown once. Store them securely. If you lose yours, rotate it via /v1/keys/rotate.
Quick Start
Choose your path: terminal (CLI), AI tool (MCP), or direct API calls.
1. Sign up
From your terminal (CLI):
unphurl signup --email you@example.com --name "Sarah"
# With a company name
unphurl signup --email you@example.com --name "Sarah" --company "Acme Corp" From Claude, ChatGPT, Cursor, or OpenClaw (MCP):
Just ask your AI: "Sign me up for Unphurl with my email you@example.com." The signup tool works without an API key.
Direct API call:
curl -X POST https://api.unphurl.com/v1/signup \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","first_name":"Sarah"}' Returns your API key (shown once, save it immediately). Check your email and click the verification link. Your key won't work until verified. Every new account gets 3 free pipeline check credits to test with real URLs.
Verification link expires in 24 hours. If it expires or you didn't receive it:
- CLI:
unphurl verify --resend --email you@example.com - AI tool: Ask: "Resend my Unphurl verification email for you@example.com"
- API:
POST /v1/verify/resendwith{"email":"you@example.com"}
2. Try your free credits
From your terminal:
unphurl https://suspicious-domain.xyz From your AI tool:
Ask: "Check this URL with Unphurl: https://suspicious-domain.xyz"
Direct API call:
curl "https://api.unphurl.com/v1/check?url=https://suspicious-domain.xyz" \
-H "Authorization: Bearer uph_your_key_here" Known domains (Google, GitHub, etc.) and cached domains are always free and don't consume credits. Your 3 free credits are for unknown domains that need the full pipeline.
3. Buy more pipeline checks
From your terminal:
# View packages
unphurl pricing
# Buy credits (opens Stripe checkout in your browser)
unphurl purchase pkg_100 From your AI tool:
Ask: "Buy me 100 Unphurl credits." Your AI will start the purchase and give you a link to complete payment.
Direct API call:
curl -X POST https://api.unphurl.com/v1/purchase \
-H "Authorization: Bearer uph_your_key_here" \
-H "Content-Type: application/json" \
-d '{"package":"pkg_100"}' Returns a Stripe Checkout URL. Open it in a browser to complete payment. Credits are added automatically. No subscription, credits never expire.
Check a URL
GET /v1/check?url={url}
Authorization: Bearer uph_your_key_here
Analyses a single URL. Returns signals, score, score breakdown, and metadata. Add ?profile=name to use a custom scoring profile.
Full pipeline response
{
"url": "https://suspicious-domain.xyz/login",
"final_url": "https://suspicious-domain.xyz/phish/page",
"domain": "suspicious-domain.xyz",
"score": 75,
"score_breakdown": [
{ "signal": "brand_impersonation", "points": 40,
"description": "impersonating paypal.com" },
{ "signal": "domain_age", "points": 35,
"description": "domain registered 3 days ago" }
],
"signals": {
"url_analysis": {
"url_length": 247,
"path_depth": 3,
"subdomain_count": 0,
"domain_entropy": 3.1,
"contains_ip": false,
"encoded_hostname": false,
"tld_changed_on_redirect": false
},
"redirects": {
"chain": [
"https://suspicious-domain.xyz/login",
"https://suspicious-domain.xyz/redir",
"https://suspicious-domain.xyz/phish/page"
],
"count": 3,
"initial_shortener": null,
"stopped_reason": null
},
"domain": {
"age_days": 3,
"registrar": "NameCheap, Inc.",
"expires_days": 362,
"status_codes": ["clientTransferProhibited"],
"nameservers": ["ns1.registrar-servers.com"],
"has_mx_record": false,
"is_parked": false,
"is_known": false,
"content_type": "text/html"
},
"ssl": { "valid": true },
"phishing": {
"is_phishing": true,
"brand_impersonation": "paypal.com"
}
},
"meta": {
"checked_at": "2026-04-03T14:22:00Z",
"latency_ms": 1240,
"cached": false,
"cache_age_hours": null,
"pipeline_check_charged": true,
"cache_ttl_remaining_hours": null
}
} Known domain response (free)
{
"url": "https://google.com",
"final_url": "https://www.google.com/",
"domain": "google.com",
"score": 0,
"score_breakdown": [],
"signals": {
"domain": { "is_known": true, "is_parked": false },
"ssl": { "valid": true },
"phishing": { "is_phishing": false }
},
"meta": {
"latency_ms": 45,
"cached": false,
"pipeline_check_charged": false
}
} Batch Check
POST /v1/check/batch
Authorization: Bearer uph_your_key_here
Content-Type: application/json
{
"urls": [
"https://google.com",
"https://suspicious-domain.xyz",
"https://bit.ly/abc123"
],
"profile": "cold-email",
"webhook_url": "https://your-server.com/results"
} - Maximum 500 URLs per request. The CLI auto-chunks larger files. The MCP server handles polling automatically.
- Duplicates are silently deduplicated (process once, charge once).
- Invalid URLs get per-URL
status: "error"without rejecting the batch. - Known/cached URLs resolve immediately. Unknowns are queued for async processing.
- Optional:
profilefor custom scoring,webhook_urlfor delivery.
Response (with pending URLs)
{
"results": [
{ "url": "https://google.com",
"status": "complete",
"result": { "...full response..." } },
{ "url": "https://suspicious-domain.xyz",
"status": "pending" },
{ "url": "https://bit.ly/abc123",
"status": "pending" }
],
"summary": {
"total": 3,
"unique": 3,
"duplicates_removed": 0,
"complete": 1,
"pending": 2,
"errors": 0,
"pipeline_checks_queued": 2
},
"job_id": "abc123-uuid-here",
"poll_url": "/v1/jobs/abc123-uuid-here"
} Job Polling
GET /v1/jobs/{job_id}
Authorization: Bearer uph_your_key_here
Poll for batch results. Job statuses: processing (still running) or completed (all done). Jobs expire after 24 hours.
{
"job_id": "abc123-uuid-here",
"status": "completed",
"results": [
{ "url": "https://suspicious-domain.xyz",
"status": "completed",
"result": { "...full response..." } }
],
"summary": {
"total": 2,
"completed": 2,
"pending": 0,
"failed": 0,
"pipeline_checks_charged": 2
}
} pipeline_checks_charged counts only successful completions. Failed pipelines are not charged.
Webhooks
Include a webhook_url in your batch request. When the job completes, Unphurl POSTs the results to your URL. Same payload format as job polling.
Every webhook includes an X-Unphurl-Signature header (HMAC-SHA256 of the body, signed with your API key hash) for verification. 3 retries with exponential backoff (1s, 5s, 25s). Results are always available via polling too.
Filtering Results
After a batch check, you'll want to split results into clean and flagged lists. Two paths depending on your workflow.
CLI (developers)
# Get only clean URLs (score under 25)
unphurl --batch urls.txt --json | \
jq -r '.results[] | select(.result.score < 25) | .url' > clean-urls.txt
# Get flagged URLs (score 50+)
unphurl --batch urls.txt --json | \
jq -r '.results[] | select(.result.score >= 50) | .url' > flagged-urls.txt
# Export full results as CSV
unphurl --batch urls.txt --json | \
jq -r '.results[] | [.url, .result.score, .result.domain, .result.signals.phishing.is_phishing] | @csv' > results.csv Claude Cowork / MCP (non-coders)
Your AI is the filter. Give it the batch and tell it what you want:
"Check these 500 URLs. Give me two lists: the clean ones (score under 25) and the flagged ones (score 50 or higher). Export both as CSV."
Claude gets the batch results via MCP, filters them, and outputs the lists. No jq, no code. This is one of the moments where the non-coder experience is actually better than the developer experience.
Known Domain Check
GET /v1/known?domain=google.com No authentication required. Returns whether a domain is in the Tranco Top 100K. Useful for predicting whether a check will be free.
{
"domain": "google.com",
"is_known": true,
"source": "tranco_top_100k"
} Account
Sign up
POST /v1/signup
{ "email": "you@example.com", "first_name": "Sarah" } Returns API key (shown once). Sends verification email. Key doesn't work until verified.
Resend verification email
POST /v1/verify/resend
{ "email": "you@example.com" } No authentication required. Rate limited to 3 per email per hour. Response is always the same regardless of whether the account exists:
{ "message": "If an account with that email exists and is not yet verified, a new verification link has been sent. Check your inbox." } Account info
GET /v1/account → { "email": "...", "verified": true, "balance": 487 } Rotate API key
POST /v1/keys/rotate New key generated, old key revoked immediately. Balance and history preserved.
Delete account
DELETE /v1/account Hard delete. Email, key, balance, history erased immediately. Irreversible.
Check history
GET /v1/history?page=1&limit=20 Paginated log of your checks. Domain, score, phishing status, timestamp. 90-day retention.
Usage statistics
GET /v1/account/stats Authenticated. Returns usage analytics for your account.
{
"usage": {
"total_urls_submitted": 12450,
"tranco_lookups": 8200,
"cache_lookups": 3890,
"pipeline_checks_run": 360,
"free_rate_pct": 97.1
},
"scoring": {
"checks_above_50": 142,
"checks_above_75": 38
},
"account": {
"credits_remaining": 145,
"total_credits_purchased": 500,
"last_active_at": "2026-04-08T14:30:00Z"
}
} usage.total_urls_submitted — total URLs you've checked (all gates)
usage.tranco_lookups — URLs that matched the Tranco Top 100K (free)
usage.cache_lookups — URLs that matched the reputation cache (free)
usage.pipeline_checks_run — URLs that ran through the full pipeline (paid)
usage.free_rate_pct — percentage of lookups that were free
scoring.checks_above_50 — URLs that scored above 50
scoring.checks_above_75 — URLs that scored above 75
account.credits_remaining — current credit balance
account.total_credits_purchased — lifetime credits purchased
account.last_active_at — last API activity timestamp
Billing
Check balance
GET /v1/balance
{
"credits_remaining": 487,
"total_purchased": 500,
"total_used": 13,
"free_lookups": 1042
} Purchase credits
POST /v1/purchase
{ "package": "pkg_500" } | Package | Credits | Price | Per credit |
|---|---|---|---|
| pkg_100 | 100 | $9 | $0.090 |
| pkg_500 | 500 | $39 | $0.078 |
| pkg_2000 | 2,000 | $99 | $0.050 |
| pkg_10000 | 10,000 | $399 | $0.040 |
View pricing
GET /v1/pricing No authentication required. Returns all packages and billing info.
Billing Behaviour
Positive balance required
All check requests require at least 1 credit on your account, even for free lookups. Known domains (Tranco) and cached domains don't deduct credits, but a positive balance is required to make the request. At 0 credits, you get a 402 response with a summary of what the URL would have cost.
Single check at 0 credits
Returns 402 with a smart summary: whether the URL is known, cached, or unknown, whether a pipeline check would be needed, and how many credits to purchase.
Batch is all-or-nothing
If you don't have enough credits for every unknown URL in the batch, none of them process. You still get a smart summary showing how many URLs are known, cached, and unknown, and exactly how many credits you need.
{
"error": "insufficient_credits",
"summary": {
"total": 100,
"tranco": 20,
"cached": 30,
"unknown": 50,
"credits_needed": 50,
"credits_remaining": 3
},
"message": "This batch has 50 unknown domains requiring pipeline checks. You have 3 credits. Purchase 47 more to run this batch.",
"purchase_url": "/v1/purchase"
} Failed pipeline = refund
If the pipeline fails completely and returns zero signals, the credit is automatically refunded. For batch jobs, each failed URL is refunded individually.
Partial success = charged
If some signals succeed but others time out (e.g., RDAP times out but SSL check works), the result is returned with null values for the failed checks. The credit is charged because you received intelligence.
Scoring Profiles
Different use cases care about different signals. Profiles are sparse overrides merged with defaults. Up to 20 per account.
Create a profile
POST /v1/profiles
{
"name": "cold-email",
"weights": {
"parked": 30,
"chain_incomplete": 25,
"no_mx_record": 20,
"expiring_soon": 15,
"ssl_invalid": 15,
"http_only": 15,
"brand_impersonation": 10,
"domain_age_7": 5
}
} Use a profile
GET /v1/check?url=https://example.com&profile=cold-email List profiles
GET /v1/profiles Delete a profile
DELETE /v1/profiles/cold-email Example profiles by use case
brand_impersonation: 40, domain_age_3: 40, domain_age_7: 30, redirects_5: 25, url_contains_ip: 15, ssl_invalid: 15, phishing_floor: 80 parked: 30, chain_incomplete: 25, no_mx_record: 20, expiring_soon: 15, ssl_invalid: 15, http_only: 15, brand_impersonation: 10 parked: 35, http_only: 20, chain_incomplete: 20, domain_status_bad: 20, no_mx_record: 15, expiring_soon: 15, ssl_invalid: 15 Scoring Weights
Default weights used when no profile is specified. Every weight can be overridden per profile.
| Signal | Key | Default | Detects |
|---|---|---|---|
| Brand impersonation | brand_impersonation | +40 | Domain resembles a major brand |
| Domain age <=3 days | domain_age_3 | +35 | Peak phishing window |
| Domain age <7 days | domain_age_7 | +25 | Registered in last week |
| Domain age <30 days | domain_age_30 | +15 | Registered in last month |
| Domain age <90 days | domain_age_90 | +5 | Registered in last 3 months |
| SSL invalid | ssl_invalid | +10 | Certificate missing, expired, or invalid |
| HTTP only | http_only | +5 | No SSL at all |
| 3-4 redirects | redirects_3 | +10 | Moderate redirect chain |
| 5+ redirects | redirects_5 | +25 | Excessive redirects |
| Chain incomplete | chain_incomplete | +15 | Could not follow full redirect chain |
| Parked domain | parked | +10 | Registrar placeholder or parking service |
| URL length >200 | url_long | +3 | Unusually long URL |
| Path depth >4 | path_deep | +3 | Deep path structure |
| Subdomain count >3 | subdomain_excessive | +5 | Excessive subdomains |
| High entropy | domain_entropy_high | +5 | Random-looking domain name |
| URL contains IP | url_contains_ip | +10 | IP address instead of domain |
| Encoded hostname | encoded_hostname | +5 | Percent-encoded characters in hostname |
| TLD changed | tld_redirect_change | +5 | TLD differs after redirect |
| Expiring soon | expiring_soon | +10 | Domain expires within 30 days |
| Bad status | domain_status_bad | +15 | pendingDelete, serverHold, etc. |
| No MX record | no_mx_record | +5 | Domain doesn't receive email |
| Compound | compound | +10 | 3+ signals detected together |
| Phishing floor | phishing_floor | 80 | Min score when brand impersonation + other signal |
How scores are calculated
- Pipeline gathers raw signals across 7 dimensions
- Each signal is checked against the weight table (default or your profile)
- Domain age uses 4 mutually exclusive brackets (only the most specific fires)
- Matching signals add their weight to the score
- If 3+ signals fire, the compound amplifier adds its weight
- If brand impersonation + any other signal, the phishing floor is enforced
score_breakdownin the response shows every contributing signal
Errors
Every error returns structured JSON with an error code and a human-readable message.
| Code | HTTP | Description |
|---|---|---|
| missing_url | 400 | No url parameter provided |
| invalid_url | 400 | URL is malformed or targets a private IP |
| profile_not_found | 400 | Specified profile name does not exist |
| invalid_name | 400 | Profile name does not match format rules |
| invalid_weights | 400 | Unknown weight keys or invalid values |
| profile_limit_reached | 400 | Account already has 20 profiles |
| invalid_api_key | 401 | API key missing, malformed, or invalid |
| insufficient_credits | 402 | No pipeline checks remaining |
| email_not_verified | 403 | Email not yet verified |
| rate_limited | 429 | Too many requests (check Retry-After header) |
{
"error": "insufficient_credits",
"message": "No pipeline checks remaining. Purchase more to continue.",
"credits_remaining": 0,
"purchase_url": "/v1/purchase"
} Rate Limits
Per API key. Designed to prevent bugs from burning credits, not to restrict normal use.
| Endpoint | Limit | Notes |
|---|---|---|
| GET /v1/check | 60/min | Single URL checks |
| POST /v1/check/batch | 10/min | Up to 500 URLs each = 5,000 URLs/min |
| GET /v1/known | 120/min | Lightweight lookup, no auth |
| POST /v1/signup | 5/hour | Per IP |
| POST /v1/purchase | 10/hour | Prevents duplicate purchases |
| All other | 60/min | Account, balance, history, profiles, jobs |
Rate limit headers in every response: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. When limited, you get a 429 with a Retry-After header.
Comparison
Unphurl provides structured signals with configurable scoring. Unlike blocklist-based tools (Google Safe Browsing, VirusTotal), Unphurl analyses domain infrastructure in real time and catches threats too new for any database. Unlike enterprise tools (DomainTools, SecurityTrails), Unphurl requires no subscription and starts at $9. Unphurl also covers both security and data quality use cases, which none of the alternatives address with a single product.
Limitations
- No safety guarantees. Heuristic detection catches patterns, not zero-day exploits or novel attacks.
- No page content scanning. Unphurl analyses the URL, domain, redirects, and TLS certificate. It does not load or render pages.
- No verdicts. No "block" or "allow" recommendations. Signals and scores only. Your AI agent decides.
- No third-party threat databases. Own heuristic analysis only. Catches pattern-based threats too new for databases.
- Tranco Top 100K trusted. Known domains return score 0 without pipeline. If compromised, Unphurl will not catch it.
- RDAP data can be incomplete. Some registries don't support RDAP. Domain age returns
nullwhen unavailable.
CLI Quick Start
Check URLs from the terminal. Colour-coded scores, JSON output, batch processing, full account management.
Check a URL
npx unphurl https://suspicious-domain.xyz Batch check from a file
unphurl --batch urls.txt --profile cold-email JSON output for piping
unphurl https://example.com --json | jq '.score' Account management
# Sign up
unphurl signup --email you@example.com --name "Your Name"
# Resend verification email
unphurl verify --resend --email you@example.com
# Check balance
unphurl --balance
# View usage statistics
unphurl stats
# Buy credits
unphurl purchase pkg_500
# Manage profiles
unphurl profiles create cold-email --weights '{"parked":30,"ssl_invalid":15}'
unphurl profiles Authentication
export UNPHURL_API_KEY=uph_your_key_here Or pass --key uph_your_key_here per command.
MCP Quick Start
Native integration with Claude (Code, Desktop, or Cowork), ChatGPT desktop, Cursor, Windsurf, and any MCP-compatible AI tool. 13 tools with full API parity.
1. Add to your MCP configuration
Add to .mcp.json:
{
"mcpServers": {
"unphurl": {
"command": "npx",
"args": ["-y", "@unphurl/mcp-server"],
"env": {
"UNPHURL_API_KEY": "uph_your_key_here"
}
}
}
} 2. Available tools
| Tool | Description | Auth |
|---|---|---|
| signup | Create a new account | No |
| check_url | Check a single URL | Yes |
| check_urls | Batch check up to 500 URLs | Yes |
| list_profiles | List scoring profiles | Yes |
| create_profile | Create or update a profile | Yes |
| delete_profile | Delete a profile | Yes |
| show_defaults | Show all scoring signals | No |
| get_balance | Check credit balance | Yes |
| get_pricing | Show packages and pricing | No |
| purchase | Purchase credits (Stripe URL) | Yes |
| check_history | View check history | Yes |
| get_stats | View usage statistics | Yes |
| resend_verification | Resend email verification link | No |
3. Companion skill (optional)
Teaches your AI to proactively check URLs before following or recommending them, without being asked.
Claude Cowork or Claude Desktop: Ask Claude: "Install the Unphurl URL safety skill." Claude will set it up for you.
Claude Code, Cursor, or other developer tools:
cp node_modules/@unphurl/mcp-server/skills/check-url-safety.md ~/.claude/skills/