Skip to main content
Auric Artisan · API documentation

Analyzer API

Published September 3, 2026

REST API reference Open the analyzer Manage API keys

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.

Table of contents

  1. 1. When to use the API
  2. 2. Quick start
  3. 3. Stages and scope
  4. 4. What page source can answer, and what it cannot
  5. 5. Why there is no overall score
  6. 6. GET /v1/analyzer/stages
  7. 7. POST /v1/analyzer/inspect
  8. 8. POST /v1/analyzer/render
  9. 9. Errors
  10. 10. Recipes
  11. 11. What is not here yet
  12. 12. Credits and limits

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 toolThis API
Runs the pageYes — in a real browser, yoursNo — it reads the source
Contrast, accessibility engine, deep DOMYesRefused, with a reason
SEO, metadata, security headers, sitemap, responsive signalsYesYes
Good forOne page, looked at closelyMany pages, on a schedule
Paid withTool tokensAPI 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.

markupWhat it meansStages
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

FieldType
urlstring, requiredAbsolute http or https. Private and reserved addresses are refused.
scopestring[]Stage ids. Omit for everything page source can answer. [] is refused.
sectionsstring[]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.

formatContent type
html (default)text/htmlA self-contained document. Around 14 KB for a small page.
pdfapplication/pdfLaid 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.

CodeStatusWhen
UNKNOWN_STAGE400A stage id that does not exist — usually a typo. The response lists the fourteen real ones.
EMPTY_SCOPE400scope: []. Omit the field for everything, or name what you want.
RENDER_REQUIRED400A stage that needs a browser. Names the offenders and what is available.
INVALID_INPUT400A missing or unusable url.
UNKNOWN_FORMAT400/render with a format that is not html or pdf.
FETCH_FAILED502Your page could not be read: DNS, TLS, a timeout, or a private address.
RENDER_FAILED500/render could not build the document from the report it was sent.
ANALYZER_UNAVAILABLE503This 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

+
EndpointCreditsWhy
GET /v1/analyzer/stages1A table read.
POST /v1/analyzer/inspect12Fetches a page you name and parses the whole document.
POST /v1/analyzer/render10Typesets 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.

REST API reference Manage API keys Open the analyzer