Analyzer API
Published September 3, 2026
Overview
Three endpoints put the URL Analyzer behind a REST call: ask what it can check,
run the checks you want against one page, and turn the result into a document.
Base URL https://api.auricartisan.com, authenticated with an API key
from your dashboard.
The part worth reading before you build on it is section 4. This endpoint reads a page’s source. Checks that need a real browser — contrast, the accessibility engine, the rendered-versus-raw comparison — are refused rather than answered from a guess, and it explains which is which before you spend anything.
1. When to use the API
+The analyzer in your browser and this API are not the same product with two front doors. They differ in one way that decides which you want:
| The tool | This API | |
|---|---|---|
| Runs the page | Yes — in a real browser, yours | No — it reads the source |
| Contrast, accessibility engine, deep DOM | Yes | Refused, with a reason |
| SEO, metadata, security headers, sitemap, responsive signals | Yes | Yes |
| Good for | One page, looked at closely | Many pages, on a schedule |
| Paid with | Tool tokens | API credits |
Use the API to watch a lot of pages for the things a crawler can see: a title
that went missing after a deploy, a canonical pointing at staging, a
Content-Security-Policy that got weakened, an image that
404s. Use the tool when you need the answers that only exist once a page has
actually been laid out and painted.
API credits and tool tokens are separate balances and neither pays for the other. See section 12.
2. Quick start
+Audit one page for search-engine metadata:
curl -X POST "https://api.auricartisan.com/v1/analyzer/inspect" \
-H "x-api-key: $AURIC_API_KEY" \
-H "content-type: application/json" \
-d '{ "url": "https://example.com/", "scope": ["seo"] }'
Ask what else it can check, and how much of each it can answer without a browser:
curl "https://api.auricartisan.com/v1/analyzer/stages" \
-H "x-api-key: $AURIC_API_KEY"
Turn a result into a PDF a client can read:
curl -X POST "https://api.auricartisan.com/v1/analyzer/render" \
-H "x-api-key: $AURIC_API_KEY" \
-H "content-type: application/json" \
-d "{ \"report\": $REPORT, \"format\": \"pdf\" }" \
--output audit.pdf
3. Stages and scope
+
A full audit is fourteen stages. scope is the
list of stage ids you want, and it is the whole of how you ask for less: send
["palette"] and you get the colours, not an audit with the
colours in it.
{ "url": "https://example.com/", "scope": ["seo", "security"] }
Omit scope and you get everything page source can answer. Send
[] and the request is refused rather than treated as
“everything” — an empty list means nothing, and running the
full audit for a request that asked for none of it would be indefensible.
Dependencies are added, and said out loud
Some stages need another one first. structure — reading the
page at all — is always on, and everything else builds on it. Whatever
gets added is echoed back so nothing happens to your request silently:
"scope": {
"requested": ["palette"],
"effective": ["structure", "palette"],
"added_by_dependency": ["structure"]
}
Sections are a projection, not a scope
The response groups the report into sections —
core, seo, palette, security
and so on. sections in the request narrows what comes back:
{ "url": "https://example.com/", "scope": ["seo"], "sections": ["seo"] }
core is always included, and this does not make a
run cheaper — the work was already done. Use scope to do
less; use sections to receive less.
4. What page source can answer, and what it cannot
+
This endpoint fetches your page and parses it. It does not run it. That is a
real limit, and rather than hide it behind plausible-looking numbers, every
stage carries a markup field with one of three values.
markup | What it means | Stages |
|---|---|---|
full |
Answered completely from the source. Nothing is missing. | structure, project, seo, security, sitemap, responsive |
partial |
A real but narrower answer. Says so in incomplete[] on every response. |
palette, media |
none |
Refused with RENDER_REQUIRED. Not returned empty, not guessed at. |
contrast, a11y, deep, performance, beforeafter, simulation |
Why those six are refused rather than estimated
Contrast is the clearest case. Measuring it needs the colour a browser computed for an element after every stylesheet, inherited value and cascade rule had its say. Without that, the only thing left is to read colours out of the stylesheet text and pair them up — which produces foreground/background pairs that may never appear together on screen. That is not an approximate contrast report. It is a confident, wrong one, and an accessibility tool that ships those is worse than one that says it cannot look.
The same argument covers the rest: the accessibility engine decides which elements to check using layout and visibility, target sizes come from measured boxes, performance figures come from a real page load, and “rendered DOM against raw HTML” has nothing to compare without a render.
What partial means in practice
palette and media need a browser for their
complete answer and have a genuine narrower one, so they are offered
with the narrowing attached:
"incomplete": [
{
"stage": "palette",
"reason": "Declared colours only. The dominant palette is sampled from rendered pixels, which needs a browser."
}
]
For palette that is every colour the stylesheets declare —
useful, and not the same thing as the colours that actually cover the most
pixels. For media it is every image, video and audio file in the
markup, with its real size fetched; anything JavaScript inserts is invisible.
incomplete[] is on the response whenever it applies. If it is
empty, nothing was narrowed.
5. Why there is no overall score
+
/v1/analyzer/inspect always returns score: null. That
is deliberate, and it is worth a paragraph because a missing number usually
looks like a bug.
An overall score is a weighted average across categories. When a run does not measure some of them, there are only bad options: leave them out and the score is not comparable with anyone else’s, or fill them in and the score is partly fiction. The analyzer has been on the wrong side of this before — a run scoped to metadata alone once reported six perfect category scores from six checks that never ran, and averaged all of them into an overall 92.
So instead you get the categories that were actually measured, and a plain list of the ones that were not:
"score": null,
"category_scores": { "metadata": 84, "seo": 85, "a11y": 68 },
"unscored_categories": [
"accessibility", "a11yPlus", "contrast", "performance",
"reliability", "security", "media", "visual", "deep", "responsive"
],
"scoreCoverage": { "measured": 3, "of": 13, "unmeasured": ["accessibility", "…"] }
If you want one number, compute it from
category_scores with weights you choose and can explain. If you
want a comparable overall score, run the audit in the tool, where everything is
measured.
Note on a11y. The a11y category is
the markup-level accessibility count — heading order, alt text, form
labels, landmarks, lang — which the source genuinely
answers. It is not the 112-rule engine; that is
accessibility and a11yPlus, and both are always in
unscored_categories here.
6. GET /v1/analyzer/stages
+The vocabulary. Call it once, cache it, and build scopes from it rather than from a list copied out of this page — it is generated from the analyzer’s own stage table, so it cannot fall out of step with what the tool does.
1 API credit. No body.
curl "https://api.auricartisan.com/v1/analyzer/stages" \
-H "x-api-key: $AURIC_API_KEY"
Response, trimmed to three of the fourteen stages:
{
"checks_per_token": 4,
"always_on": ["structure"],
"stages": [
{
"id": "seo",
"name": "SEO and metadata",
"does": "Title, description, headings, schema, robots, canonical, keyword density.",
"section": "seo",
"needs_render": false,
"markup": "full",
"endpoint": "/v1/analyzer/inspect",
"caveat": null,
"why_unavailable": null
},
{
"id": "palette",
"name": "Palette",
"does": "Dominant colours and generated accessible palettes.",
"section": "palette",
"needs_render": true,
"markup": "partial",
"endpoint": "/v1/analyzer/inspect",
"caveat": "Declared colours only. The dominant palette is sampled from rendered pixels, which needs a browser.",
"why_unavailable": null
},
{
"id": "contrast",
"name": "Colour contrast",
"does": "Every text pair measured against WCAG, with a passing colour to replace it.",
"section": "contrast",
"needs_render": true,
"markup": "none",
"endpoint": null,
"caveat": null,
"why_unavailable": "Needs a rendered page: computed styles and layout. Markup alone would be a guess, and a wrong one."
}
],
"sections": [ { "id": "seo", "label": "SEO", "panel": "seo", "keys": ["seoAudit", "…"] } ],
"notes": [ "scope is a list of stage ids. Dependencies are added for you…" ]
}
Filtering on markup !== "none" gives you every stage this endpoint
will accept. always_on tells you what will be added to your scope
whatever you send.
7. POST /v1/analyzer/inspect
+12 API credits. One page per call.
Request
| Field | Type | |
|---|---|---|
url | string, required | Absolute http or https. Private and reserved addresses are refused. |
scope | string[] | Stage ids. Omit for everything page source can answer. [] is refused. |
sections | string[] | Narrows the response only. core is always included. |
Response
{
"url": "https://example.com/",
"final_url": "https://example.com/",
"audit_version": "3.0",
"scope": {
"requested": ["seo"],
"effective": ["structure", "seo"],
"added_by_dependency": ["structure"]
},
"score": null,
"unscored_categories": ["accessibility", "contrast", "performance", "…"],
"category_scores": { "metadata": 84, "seo": 85, "a11y": 68 },
"timing": { "wallMs": 34, "fetchMs": 11, "fetchVia": "browser-fetch" },
"section_index": [
{ "id": "core", "label": "Summary", "panel": "overview", "keys": 12, "n": 23, "bytes": 4210 },
{ "id": "seo", "label": "SEO", "panel": "seo", "keys": 2, "n": 7, "bytes": 7907 }
],
"sections": {
"core": { "url": "…", "meta": { "title": "…", "canonical": "…" }, "siteInfo": { … } },
"seo": { "seoAudit": { "score": 85, "findings": [ … ] }, "metadataAudit": { … } }
},
"incomplete": []
}
section_index is a cheap summary of the payload — how many
findings each section holds and how many bytes it costs — so you can log
or store it without keeping the whole thing.
Only what it measured comes back
A stage outside your scope contributes nothing to the response. Not an empty
object under a familiar key — nothing. Asking for ["seo"]
returns no security section, and no run of any scope returns
contrastPairs, a11yAudit or deepAnalysis,
because those are the checks this endpoint refuses. Refusing to accept a stage
and then shipping its output anyway would be worse than either.
8. POST /v1/analyzer/render
+
10 API credits. Takes a report — one
/v1/analyzer/inspect returned, or one you stored — and
renders it. No page is fetched, so this costs nothing in network time and works
on a report from last month.
format | Content type | |
|---|---|---|
html (default) | text/html | A self-contained document. Around 14 KB for a small page. |
pdf | application/pdf | Laid out server-side. Around 7 KB for the same page. |
curl -X POST "https://api.auricartisan.com/v1/analyzer/render" \
-H "x-api-key: $AURIC_API_KEY" \
-H "content-type: application/json" \
-d "{ \"report\": $REPORT, \"format\": \"pdf\" }" \
--output audit.pdf
The PDF is text and rules only — no screenshots —
and it is typeset with the standard PDF fonts, which cover Latin characters.
Content in other scripts will not appear in it. Use html when the
page you audited is not in a Latin script.
Why Markdown is not offered
The analyzer writes a developer report and a client report in Markdown, and they are not available here. They are built by a module that expects a live page around it, so it cannot run on a server — and listing a format that fails when you call it would be worse than leaving it out. Both are still one click in the tool, from a saved audit, with no rescan.
9. Errors
+
Every one of these except FETCH_FAILED and RENDER_FAILED is decided before your page is fetched, so a
rejected request does no work.
| Code | Status | When |
|---|---|---|
UNKNOWN_STAGE | 400 | A stage id that does not exist — usually a typo. The response lists the fourteen real ones. |
EMPTY_SCOPE | 400 | scope: []. Omit the field for everything, or name what you want. |
RENDER_REQUIRED | 400 | A stage that needs a browser. Names the offenders and what is available. |
INVALID_INPUT | 400 | A missing or unusable url. |
UNKNOWN_FORMAT | 400 | /render with a format that is not html or pdf. |
FETCH_FAILED | 502 | Your page could not be read: DNS, TLS, a timeout, or a private address. |
RENDER_FAILED | 500 | /render could not build the document from the report it was sent. |
ANALYZER_UNAVAILABLE | 503 | This deployment has no analyzer backend. /v1/analyzer/stages still works. |
A refusal tells you what to do about it:
{
"error": {
"code": "RENDER_REQUIRED",
"message": "contrast needs a rendered page, which this endpoint does not do. GET /v1/analyzer/stages lists what markup alone can answer.",
"details": {
"needs_render": ["contrast"],
"available": ["structure", "project", "palette", "seo", "media", "responsive", "security", "sitemap"]
}
},
"request_id": "7140da9b-0ddf-417d-9c8b-9775a5056a01"
}
Quote the request_id if you get in touch about a specific call.
The shared 401/403/404/405/413/429 responses are documented on the
REST API reference.
10. Recipes
+Watch a page for metadata regressions
The cheapest useful thing this API does: catch a title, description or canonical that changed when nobody meant it to.
const res = await fetch("https://api.auricartisan.com/v1/analyzer/inspect", {
method: "POST",
headers: { "x-api-key": process.env.AURIC_API_KEY, "content-type": "application/json" },
body: JSON.stringify({ url, scope: ["seo"], sections: ["seo"] }),
});
const { sections, category_scores } = await res.json();
const meta = sections.seo.metadataAudit;
if (category_scores.seo < 80) {
console.error(`${url}: SEO fell to ${category_scores.seo}`);
for (const finding of sections.seo.seoAudit.findings) console.error(` ${finding.title}`);
process.exitCode = 1;
}
Audit every page in a sitemap
One call per page, so pace it against your rate limit — 300 requests a minute on Specialist, 3,000 on Industrial Pro.
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
const res = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify({ url, scope: ["seo", "security"] }) });
if (res.status === 429) { // slow down and retry this one
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") || 5) * 1000));
i--;
continue;
}
const audit = await res.json();
results.push({ url, scores: audit.category_scores, findings: audit.sections });
}
x-quota-remaining comes back on every response, so you can stop
before you run out rather than discovering it at page 400.
Just the colours
curl -X POST "https://api.auricartisan.com/v1/analyzer/inspect" \
-H "x-api-key: $AURIC_API_KEY" -H "content-type: application/json" \
-d '{ "url": "https://example.com/", "scope": ["palette"], "sections": ["palette"] }'
Returns sections.palette.colors (the declared colours, bucketed by
where they are used) and sections.palette.colorAudit, with the
“declared, not sampled” caveat in incomplete[].
A PDF for a client, from a stored audit
const audit = await inspect(url, ["seo", "security", "responsive"]);
const report = Object.assign({}, ...Object.values(audit.sections));
const pdf = await fetch("https://api.auricartisan.com/v1/analyzer/render", {
method: "POST",
headers: { "x-api-key": key, "content-type": "application/json" },
body: JSON.stringify({ report, format: "pdf" }),
});
await writeFile("audit.pdf", Buffer.from(await pdf.arrayBuffer()));
/render takes the whole report, so merge the sections back
together before sending it. Nothing is re-fetched.
11. What is not here yet
+Two things are missing on purpose rather than by oversight.
A rendered scan. The endpoint that would answer contrast, the accessibility engine and the deep DOM comparison needs a real browser. One exists in our infrastructure — it is what the tool uses — but it lives in a different service from this API, and wiring the two together is a piece of work rather than a switch. Until it is done, those stages are refused here and available in the tool.
A crawl. Auditing a whole site takes minutes, and an HTTP
request that takes minutes is a bad shape. It needs a job you start and poll,
which this API does not yet have for work of that size. In the meantime,
fetching a sitemap yourself and calling /inspect per page does the
same thing with your own pacing — see
section 10.
12. Credits and limits
+| Endpoint | Credits | Why |
|---|---|---|
GET /v1/analyzer/stages | 1 | A table read. |
POST /v1/analyzer/inspect | 12 | Fetches a page you name and parses the whole document. |
POST /v1/analyzer/render | 10 | Typesets a document. No network. |
The price is per call, not per stage. A scope of one stage and a scope of eight cost the same 12 credits, because the meter is charged from the endpoint before the body is read. Narrowing the scope makes a run faster and the response smaller; it does not make it cheaper. If you want the whole picture, ask for it in one call rather than several.
Every response carries x-credits-cost,
x-quota-remaining and the rate-limit headers. API credits are a
separate balance from the tool tokens the analyzer spends in your browser, and
neither pays for the other — plans, allowances and overage are on the
REST API reference.