What you can call
ThemeSniffer analyzes any public website from the outside and answers four questions: does it run WordPress, what is it built with, what fonts does it use, and what colors. The website, the API, the MCP server, and the WebMCP tools all run the same detection code, so all four give the same answer.
| Surface | Address | Use it when |
| HTTP JSON API | https://themesniffer.com/api/… | You are writing code: a script, a backend job, a spreadsheet import. |
| MCP server | https://themesniffer.com/api/mcp | You want an AI assistant to run these analyses as tools, with interactive views rendered in the conversation. |
| WebMCP tools | Registered on every tool page | A browser agent is already on the page and should call a tool instead of filling in the form. |
| OpenAPI 3.1 spec | https://themesniffer.com/openapi.json | You want generated clients, or an agent needs the machine-readable contract. |
Quickstart
One request, no setup
Ask whether a site runs WordPress. This is the whole integration:
curl "https://themesniffer.com/api/check-wordpress?url=wordpress.org"
The response:
{
"success": true,
"isWordPress": true,
"confidence": "high",
"signals": {
"metaGenerator": "WordPress 6.7.1",
"wpContent": true,
"wpIncludes": true,
"wpBlocks": true,
"restApi": true,
"xPoweredBy": false,
"linkHeader": true,
"xPingback": false
},
"signalCount": 6,
"totalSignals": 8,
"poweredBy": "PHP/8.2.15",
"theme": {
"name": "Twenty Twenty-Four",
"slug": "twentytwentyfour",
"nameSource": "style.css",
"inRepo": true,
"wpOrgUrl": "https://wordpress.org/themes/twentytwentyfour/",
"version": "1.3",
"versionSource": "style.css",
"latestVersion": "1.6",
"outdated": true,
"author": "the WordPress team",
"activeInstalls": 500000
},
"url": "https://wordpress.org/",
"checkedAt": "2026-09-08T10:15:00.000Z"
}
JavaScript
const res = await fetch(
'https://themesniffer.com/api/tech-stack?url=' + encodeURIComponent('example.com')
);
const data = await res.json();
if (!data.success) throw new Error(data.error);
console.log(data.theme?.name, data.plugins.map(p => p.name));
CORS is open (Access-Control-Allow-Origin: *) on every endpoint, so this works from a browser as well as from a server.
Python
import requests
r = requests.get(
"https://themesniffer.com/api/tech-stack",
params={"url": "example.com"},
headers={"User-Agent": "my-research-script/1.0 ([email protected])"},
timeout=60,
)
data = r.json()
if not data.get("success"):
raise RuntimeError(data["error"])
print(data["theme"], data["hosting"], data["performance"]["score"])
Bulk analysis
Analyzing a list of domains is a loop with a delay. Stay inside the published rate limits, send a descriptive User-Agent so a problem can be traced back to you, and cache results: sites do not change their theme between two requests a second apart.
for domain in $(cat domains.txt); do
curl -sS --max-time 60 \
-H "User-Agent: my-bulk-audit/1.0 ([email protected])" \
"https://themesniffer.com/api/tech-stack?url=$domain" >> results.jsonl
sleep 8
done
Authentication and rate limits
There is no API key
The analysis endpoints are public and unauthenticated. There is no key to request, no account to create, and no header to send. Access is governed by per-IP rate limits instead of credentials.
Two request properties do decide whether a call is accepted, and both trip up naive clients:
| Header | Rule | Why |
User-Agent |
Required, and at least 10 characters. |
Empty or one-word agent strings are almost always scrapers. Send something that identifies your project and a way to reach you. |
Origin |
If present, must be themesniffer.com or localhost. |
Stops other sites embedding the endpoints as a free backend. Server-to-server callers send no Origin at all and are unaffected — this only constrains browser calls from a third-party page. |
Calling from your own web app? Browsers set Origin automatically, so a fetch() from yourapp.com to the analysis endpoints is rejected with HTTP 403. Call from your backend, or use the MCP endpoint, which accepts any origin by design.
Rate limits
Limits are per IP address, per rolling minute. A limited request returns HTTP 429 with a JSON body; there is no Retry-After header, so wait a minute and retry.
| Endpoint | Requests per minute |
/api/check-wordpress | 10 |
/api/tech-stack | 8 |
/api/font-detector | 8 |
/api/color-palette | 8 |
/api/mcp | 30 JSON-RPC requests |
Successful analyses are cacheable for 300 seconds (Cache-Control: public, max-age=300); errors and refusals are never cached. Repeating the same URL inside that window is close to free.
Fair use
Every call makes ThemeSniffer fetch a third-party website on your behalf, so heavy use costs someone else bandwidth as well as us. Cache aggressively, do not re-scan unchanged sites, and if you need volume beyond these limits, get in touch rather than routing around them.
Endpoint reference
Base URL: https://themesniffer.com/api. Every analysis endpoint is a GET that takes one query parameter, url, and responds with JSON.
The url parameter accepts a bare domain. example.com, https://example.com, and https://example.com/ all resolve to the same request. Redirects are followed and re-validated at every hop, and the response reports the final URL that was actually analyzed.
GET
/api/check-wordpress
Is this website WordPress? The lightest endpoint: one page fetch plus an optional probe of /wp-json/.
Response fields
| Field | Type | Meaning |
isWordPress | boolean or null | True when at least one signal matched, false when none did, and null when the page could not be read at all - a block, a bot challenge, a dead origin. Never treat null as "not WordPress": see blocked and note. |
blocked, blockedBy | boolean, string or null | blocked is true when the site refused the request or served a bot challenge. blockedBy names the CDN or WAF when its headers identify it, for example "Cloudflare". |
reason, status | string, integer | Why the page could not be read (bot-protection, challenge, rate-limited, unauthorized, not-found, origin-down, server-error, not-html) and the HTTP status behind it. Both absent on a completed analysis. |
note | string or null | One sentence a person can read: what stopped the check, and whether the site is blocking or actually down. |
poweredBy | string or null | The raw X-Powered-By header, unfiltered - usually a PHP version. The xPoweredBy signal is false unless that value names WordPress; this field is how you tell an absent header from one that simply says something else. |
confidence | string | high with three or more signals or a WordPress generator tag, medium with two, low with one, none otherwise. |
signals | object | The eight checks, each false or truthy. metaGenerator and xPoweredBy carry the matched value rather than true, and each signal is named for what it tests, not for where it looks: xPoweredBy is false on a site sending x-powered-by: PHP/8.1, because the header exists but a PHP version is not WordPress evidence (/api/tech-stack reports that raw header as poweredBy). linkHeader and xPingback match either the HTTP header or the equivalent <link> tag in the document head - a page-cache plugin serves the tags without ever setting the headers. |
signalCount | integer | How many of totalSignals matched. |
theme | object or null | Theme name and slug, enriched with version, author, screenshot and install count when the slug is published on WordPress.org. nameSource says where the name came from: style.css (read from the theme itself), wordpress.org, or slug (inferred from the folder name, so a guess). wpOrgUrl is present only when inRepo is true - custom and premium themes have no directory page. See theme versions and install counts. |
checkedAt | ISO 8601 | When the analysis ran. |
Theme versions and install counts
Four fields on theme are easy to read as one thing and are not:
version is what the site has installed, read from its own style.css. versionSource says so; when style.css could not be fetched it falls back to the directory's release and versionSource reads wordpress.org.
latestVersion is the current release in the WordPress.org directory, and outdated compares the two - but only when the installed version really was read from the site, because comparing the directory's release against itself would call every theme current.
activeInstalls is how many sites run the theme, and WordPress.org buckets it: 500000 means "500,000+", never exactly 500,000. Display it with the plus.
downloads is lifetime downloads, typically an order of magnitude larger, and is not a measure of current usage.
Human version of this endpoint: the Is It WordPress? checker.
GET
/api/tech-stack
The full report, and the right default when the question is open-ended. Adds plugins, hosting, CDN, server, security headers, page composition and a speed snapshot to everything /api/check-wordpress returns.
Response fields
| Field | Type | Meaning |
theme | object or null | As above, including inRepo and nameSource. |
blocked, blockedBy, reason, note | as above | Same refusal fields as /api/check-wordpress, and isWordPress is null for the same reason. |
plugins | array | Each entry has slug, name and known. Recognised slugs get their real product name; unrecognised ones are title-cased from the slug. |
hosting, cdn | object or null | { name, type, via } where via names the response header the match came from. Null means no signature matched, not that there is no host. |
server, poweredBy | string or null | The raw Server and X-Powered-By response headers, unfiltered - poweredBy is usually the PHP version. |
performance | object | score 0-100 with a letter grade, serverResponseMs, ttfbRating, htmlKb, compression, requests. Measured from our edge to the site: a lab snapshot, not the visitor's Core Web Vitals. htmlKb is the uncompressed document, and compression/compressed are null when the wire encoding was not observable from our edge - that means undetermined, not "compression is off", and it is excluded from score rather than counted against it. |
security | object | Booleans for HTTPS, HSTS, CSP, X-Frame-Options and X-Content-Type-Options. |
Human version: the tech stack report. Manual version: how to check a WordPress tech stack by hand.
GET
/api/font-detector
Every font the site uses. Fetches the page, collects its stylesheets within a byte and time budget, and parses @font-face rules, provider URLs and font-family declarations. Works on any website, WordPress or not.
Response fields
| Field | Type | Meaning |
fonts | array | One entry per family: family, source, weights, styles, variable, isPrimary, fallbacks, files. |
sources | object | Per-provider detection: google, bunny, fontshare, adobe (with kitId), fontsCom, selfHosted (with fileCount). |
systemStacks | array | System font stacks declared in the CSS, kept separate from real families. |
wpFontPresets | array | Font presets from the theme's theme.json, when present. |
typeScale | array | Distinct font-size values as { value, count }, ascending by pixel equivalent. |
stylesheets | object | How much CSS was reachable: found, fetched, failed, inlineBlocks, cssBytes. A low fetched means a thinner answer. |
Human version: the font detector.
GET
/api/color-palette
The site's palette, parsed out of its CSS rather than sampled from a screenshot, so the values are the ones the designer actually wrote. Handles hex, rgb(), hsl(), oklch() and oklab(), and resolves custom properties.
Response fields
| Field | Type | Meaning |
palette | array | Clustered colors with hex, rgb, hsl, occurrence count, the roles the color plays and its topRole. |
roles | object | The single best candidate for background, text, primary, accent and border. Any of them can be null. |
wpPalette | array | Colors declared in the theme's theme.json presets, with slug and name. |
gradients | array | Gradient declarations found in the CSS. |
contrast | array | WCAG contrast ratios for the pairs that matter: body text on the background, brand color as text, and label colors on a brand-colored button. |
stats | object | Declarations parsed, unique colors, clusters after grouping, and custom properties seen. |
Human version: the color palette extractor.
POST
/api/mcp
The MCP server. JSON-RPC 2.0 over HTTP, documented in full below.
Sandbox
Run a real request against the live API without leaving the page. Same endpoints, same rate limits, no key. Pick an endpoint, give it a site, and read the raw JSON it returns.
Ready.
Equivalent request
curl "https://themesniffer.com/api/tech-stack?url=wordpress.org"
Response
Send a request to see the response.
Errors
Errors are JSON, never HTML, and always carry a message written for a person to read. The shape is stable:
{ "success": false, "error": "Cannot analyze private, local, or reserved addresses." }
Check success rather than the status code alone — a slow or hostile target can produce a body with success: false.
| Status | Cause | What to do |
400 | Missing, malformed, or over-long url, or an address in a private, local or reserved range. | Fix the input. Retrying will not help. |
403 | User-Agent missing or under 10 characters, or an Origin from a third-party site. | Send a descriptive User-Agent; call from a backend rather than a browser on another domain. |
429 | Per-IP rate limit exceeded for that endpoint. | Back off for a minute. Cache results so you do not ask twice. |
502 | The target website could not be fetched at all: the connection failed or timed out. A site that answers and refuses is not a 502 - see when the target blocks us below. | Retry once after a pause. |
When the target blocks us
A site that refuses our request is a different thing from a site with nothing to report, and the API never conflates them. When the page cannot be read, the response is still 200 with success: true, but the verdict fields are null rather than false and the refusal is stated:
{
"success": true,
"isWordPress": null,
"confidence": "unknown",
"blocked": true,
"blockedBy": "Cloudflare",
"status": 403,
"reason": "bot-protection",
"note": "Cloudflare, which fronts this site, blocked our request (HTTP 403), so we could not check it for WordPress. That is a bot-protection rule, not an outage - the site itself is up and serves normal browsers.",
"signalCount": 0,
"url": "https://example.com/",
"checkedAt": "2026-09-08T10:15:00.000Z"
}
So: branch on blocked (or isWordPress === null) before reading a verdict. A bot challenge served with a 200 is detected too, so an interstitial never gets analyzed as if it were the site.
Blocked addresses
Requests to private, loopback, link-local, CGNAT and cloud metadata addresses are refused, and so are the alternate encodings that usually smuggle them past a check: integer and hexadecimal IPs, octal-style octets, and IPv4-mapped IPv6. Redirects are re-validated on every hop, so a public URL cannot bounce a request onto an internal network.
MCP server
ThemeSniffer runs a remote Model Context Protocol server, so an AI assistant can analyze websites as a first-class tool instead of being told to browse. It speaks Streamable HTTP at a single endpoint:
https://themesniffer.com/api/mcp
Connect a client
For a client that supports remote MCP servers over HTTP, add:
{
"mcpServers": {
"themesniffer": {
"type": "http",
"url": "https://themesniffer.com/api/mcp"
}
}
}
For a client that only speaks stdio, bridge it:
{
"mcpServers": {
"themesniffer": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://themesniffer.com/api/mcp"]
}
}
}
No authentication is configured, so there is no token to paste. The server is stateless: it issues no Mcp-Session-Id, and it opens no SSE stream, so a GET to the endpoint returns 405 by design. Send JSON-RPC with POST.
Tools
| Tool | Argument | Returns |
check_if_wordpress | url | Verdict, confidence, the eight signals, and the theme. |
get_wordpress_tech_stack | url | Theme, plugins, hosting, CDN, server, security headers, speed. |
detect_website_fonts | url | Families, providers, system stacks, presets, type scale. |
extract_color_palette | url | Palette, role assignments, theme.json colors, gradients, contrast. |
Each tool answers twice: prose in content for the model to read, and the complete API response in structuredContent for anything that parses.
MCP Apps: interactive views
Every tool declares a UI template through the MCP Apps extension, so a host that supports it renders a real interface in the conversation — a swatch grid for the palette, a signal chip row for the WordPress verdict — instead of printing JSON. Tools carry the pointer in _meta:
{
"name": "extract_color_palette",
"inputSchema": { "type": "object", "properties": { "url": { "type": "string" } } },
"_meta": {
"ui": {
"resourceUri": "ui://themesniffer/extract_color_palette",
"visibility": ["app"]
}
}
}
Reading that resource returns the view as text/html;profile=mcp-app. The server advertises the capability during initialize:
{
"capabilities": {
"tools": { "listChanged": false },
"resources": { "listChanged": false, "subscribe": false },
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
Inside the frame the view does the standard bridge handshake over postMessage — ui/initialize, then it waits for ui/notifications/tool-result and draws the payload. It reads the host theme, and its one action, opening the full report on this site, goes through ui/open-link rather than a raw anchor. A host that ignores the extension still gets the text summary, so nothing is lost.
Try it with curl
curl -sS https://themesniffer.com/api/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -sS https://themesniffer.com/api/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"check_if_wordpress","arguments":{"url":"wordpress.org"}}}'
Supported methods: initialize, ping, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list. Protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 are negotiated during initialize.
Failures come back as a tool result with isError: true and a readable message, not as a JSON-RPC protocol error — an unreachable website is an answer the model can act on, not a broken call.
WebMCP: tools inside the page
A browser agent that lands on a ThemeSniffer page does not have to work out which input is the URL field and which div holds the result. Every page that can run an analysis registers the same four tools through WebMCP, so the agent calls a function and gets structured data back.
document.modelContext.registerTool({
name: 'check_if_wordpress',
description: 'Check whether a website is built with WordPress…',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
async execute({ url }) {
// …calls /api/check-wordpress and returns
// { content: [{ type: 'text', text }], structuredContent }
},
});
Registration falls back through navigator.modelContext.registerTool() and then navigator.modelContext.provideContext() for agents on earlier drafts of the proposal. Where no surface exists the script is inert and the forms behave exactly as they always did. The implementation is one file, /webmcp.js, and the tool names and schemas match the MCP server exactly, so an agent that has used one already knows the other.
No WebMCP host? Call them anyway
Most agents driving a browser today have no modelContext to register into, so window.ThemeSnifferWebMCP.supported reads false and surface is null. That is the expected state, and it does not mean the tools are unavailable - it only means nothing native picked them up. Any agent that can run JavaScript in the page calls them directly, with the same arguments and the same result shape:
await window.ThemeSnifferWebMCP.callTool('check_if_wordpress', { url: 'example.com' });
// { content: [{ type: 'text', text }], structuredContent: { … } }
window.ThemeSnifferWebMCP.tools.map(t => t.name);
// ['check_if_wordpress', 'get_wordpress_tech_stack', 'detect_website_fonts', 'extract_color_palette']
A call that could not read the target site comes back with isError: true and a text summary that says who blocked it, rather than an empty report that reads like a finding.
The tool forms also carry declarative toolname and tooldescription attributes, which give an agent server-rendered evidence of what the page can do before any script has run. That part of the proposal is still a preview, so it supplements the registered tools rather than replacing them.
When an agent calls a tool on a page that has the matching form, the page renders the result too, so a human watching the screen sees the same answer the agent got.
Machine-readable discovery
Everything an agent needs to find and read this site without guessing:
OpenAPI 3.1
/openapi.json describes every endpoint, parameter, response schema and error, with worked examples. Linked from this page's head as rel="service-desc".
llms.txt
/llms.txt summarises what the product is, what the tools do, and where the Markdown twins live.
Markdown twins
Every page has a Markdown version at the same path with a .md extension — this page is /developers.md. Roughly 75% smaller than the HTML, and advertised in each page's head as rel="alternate".
MCP
POST /api/mcp for tool access, with MCP Apps ui:// views for hosts that render them.
What detection cannot see
Worth knowing before you build a product on top of these numbers, because no external scanner can do better:
- Plugins with no front-end footprint are invisible. Detection reads the CSS and JS a page loads. A backup plugin, an SMTP plugin or a security plugin that ships no asset leaves no trace to find.
- Bundled and minified assets hide their origin. A site that concatenates all plugin CSS into one file has erased the paths detection relies on, so it will look emptier than it is.
- Speed numbers are a lab snapshot. They are measured from our edge to the site once. They are useful for comparing sites in the same run, not as a substitute for field Core Web Vitals.
- Hosting and CDN come from header signatures. A null result means no signature matched, not that a site has no host. A CDN in front of the origin usually masks the origin host entirely.
- Bot filters win. A site behind an aggressive WAF refuses us the same way it would any automated client. The endpoint says so rather than guessing -
blocked: true, the blocker named where its headers identify it, and no verdict - but it cannot see past the wall.
- The CSS budget is finite. The font and color endpoints fetch stylesheets up to a byte and time cap. Check
stylesheets.fetched against stylesheets.found to see whether you got the whole picture.
Support and changes
The API is versionless. New fields get added to responses; existing fields are not renamed or removed without a note here. Treat unknown fields as additive and do not depend on key order.
Questions, a bug in a detection result, or a volume need beyond the published limits: the contact page or @ruslan_dev_ai. A report that names the URL you analyzed and what you expected is worth ten that do not.
Changelog
| Date | Change |
| 2026-09-09 | theme.activeInstalls now carries the directory's active-install count; it previously carried lifetime downloads, roughly 20x larger. Added theme.downloads, theme.latestVersion, theme.versionSource and theme.outdated, and poweredBy on /api/tech-stack. |
| 2026-09-09 | A site that blocks or challenges the request no longer returns a verdict: isWordPress is null and the response carries blocked, blockedBy, reason and note. Theme names are read from the theme's own style.css where reachable (nameSource says which), and wpOrgUrl is present only for themes really in the directory. The REST API and pingback signals now count the <link> tags as well as the headers. |
| 2026-09-08 | Developer portal published. Added the MCP server at /api/mcp with MCP Apps ui:// views, WebMCP tool registration on the tool pages, and the OpenAPI 3.1 spec at /openapi.json. |