SEO report API
Call the API from code

SEO report API in Node.js: audit a site and read the findings

A dependency-free Node.js 18+ script that audits one site with the SEOReport API, waits for the report, and prints the score, band and top three fixes.

This script runs one SEOReport audit end to end: it creates the report, polls it until it finishes, downloads the JSON diagnosis, and prints the overall score, its band and the first three open findings with their fixes. [1][2]

It has no dependencies. fetch is a global in Node.js 18 and later, and the script is an ES module that uses top-level await, so there is no package.json and nothing to npm install. [3][4]

What you need

A paid SEOReport plan and an API key created in your account settings. API access is account-linked and key-gated: every request carries the key as a Bearer token, which the API reference shows as Authorization: Bearer sr__live_your_api_key. [2][1]

Node.js 18 or later. Node 18.0.0 took fetch out from behind the --experimental-fetch flag, and Node 21.0.0 marked it stable; the AbortSignal.timeout the script uses to cap each request dates from Node 17.3.0. [3]

The script reads the key from the SEOREPORT_API_KEY environment variable through process.env, so the key never sits in the source file or in version control. [6][2]

Each new site uses one of your plan's credits; running a site you already have a report for again does not. Credits reset on the first day of each month (UTC) and never roll over. The script audits https://example.com unless you pass another URL as its first argument. [1]

The full script

Save it as recipe.mjs, so Node loads it as an ES module, and run node recipe.mjs https://your-site.example with SEOREPORT_API_KEY set. [4][2]

recipe.mjsjavascript
// Audit one site with the SEOReport API and print its top three fixes.
// Usage: SEOREPORT_API_KEY=... node recipe.mjs [https://example.com]
// Node.js 18 or later: global fetch, ES modules, no dependencies.
import { writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const API = "https://seoreport.dev/api/v1/reports";
const MAX_WAIT_MS = 300_000; // stop waiting after five minutes
const DEFAULT_POLL_MS = 5_000; // used only when the API sends no pollAfterMs

class ApiRefusal extends Error {}

const key = (process.env.SEOREPORT_API_KEY ?? "").trim();
if (!key) {
  console.error("Set SEOREPORT_API_KEY to an API key from your SEOReport plan.");
  process.exit(2);
}
const target = process.argv[2] ?? "https://example.com";

/** Send one request; return { status, headers, text } for any HTTP status. */
async function request(method, url, body) {
  const headers = {
    Authorization: `Bearer ${key}`,
    Accept: "application/json",
    "User-Agent": "seoreport-recipe-node/1",
  };
  if (body !== undefined) headers["Content-Type"] = "application/json";
  const res = await fetch(url, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(60_000),
  });
  return { status: res.status, headers: res.headers, text: await res.text() };
}

function parse({ status, text }) {
  try {
    return JSON.parse(text);
  } catch {
    throw new ApiRefusal(`HTTP ${status}: not JSON: ${text.slice(0, 200)}`);
  }
}

/** Turn an error response into one line that names the API's own code. */
function refuse({ status, headers }, payload) {
  const code = payload.code ?? payload.errorCode ?? `HTTP_${status}`;
  let line = `${code} (HTTP ${status}): ${payload.error ?? "no message"}`;
  if (payload.ctaUrl) line += ` -> https://seoreport.dev${payload.ctaUrl}`;
  const retryAfter = headers.get("retry-after");
  if (retryAfter) line += ` [retry after ${retryAfter}s]`;
  throw new ApiRefusal(line);
}

async function main() {
  // 1. Create the report (or reuse the one this account already has).
  let res = await request("POST", API, { url: target });
  const created = parse(res);
  if (res.status >= 400 || !created.success) refuse(res, created);
  const jobId = created.report.jobId;
  let state = created.report.status;
  let pollMs = created.pollAfterMs || DEFAULT_POLL_MS;
  console.log(`job ${jobId}: ${state}`);

  // 2. Poll politely until the job leaves queued/running, with a hard cap.
  const deadline = Date.now() + MAX_WAIT_MS;
  let job = created;
  while (state === "queued" || state === "running") {
    if (Date.now() + pollMs > deadline) {
      throw new ApiRefusal(`gave up after ${MAX_WAIT_MS / 1000}s; job ${jobId} is still ${state}`);
    }
    await sleep(pollMs);
    res = await request("GET", `${API}/${jobId}`);
    job = parse(res);
    if (res.status >= 400 || !job.success) refuse(res, job);
    state = job.report.status;
    if (job.stuck) throw new ApiRefusal(`job ${jobId} stopped making progress; start a new report`);
    pollMs = job.pollAfterMs || DEFAULT_POLL_MS;
  }

  if (state !== "completed") {
    const err = job.error ?? {};
    const retry = err.retryable ? " (retryable)" : "";
    throw new ApiRefusal(`report ${state}: ${err.code ?? state}: ${err.message ?? ""}${retry}`);
  }

  // 3. Download the JSON diagnosis and keep a copy.
  res = await request("GET", `${API}/${jobId}/download?format=json`);
  const report = parse(res);
  if (res.status >= 400) refuse(res, report);
  const path = `seoreport-${jobId}.json`;
  await writeFile(path, res.text);

  // 4. Print the score, the band and the first three open findings.
  console.log(`${report.targetUrl}: ${report.score.overall}/100 (${report.score.band})`);
  console.log(`open issues: ${report.openIssues.total}, pages analysed: ${report.pagesAnalyzed}`);
  const openFindings = report.findings.filter((f) => f.status === "fail" || f.status === "warn");
  for (const finding of openFindings.slice(0, 3)) {
    const severity = finding.severity ?? finding.status;
    console.log(`\n#${finding.rank} [${severity}] ${finding.title} (${finding.area.name})`);
    console.log(`   Fix: ${finding.fix}`);
  }
  if (report.message) console.log(report.message);
  console.log(`\nfull diagnosis saved to ${path}`);
}

try {
  await main();
} catch (error) {
  console.error(error instanceof ApiRefusal ? `SEOReport: ${error.message}` : `error: ${error.cause?.message ?? error.message}`);
  process.exit(1);
}

Three endpoints carry the whole flow. POST /api/v1/reports creates the report, or returns the one you already have, and answers with its jobId. GET /api/v1/reports/:id reports the status until it is no longer queued or running. GET /api/v1/reports/:id/download?format=json exports the finished diagnosis. [1][2]

Every successful create and status response carries pollAfterMs, the wait the API asks for before the next status read, and 0 once the report is finished. The script waits at least that long, falls back to 5 seconds when the field is missing or 0, and stops waiting after 5 minutes rather than polling forever. [1][2]

A fetch promise does not reject on an error status such as 402 or 429; it rejects only when the request itself fails, for example on a network error. So the script reads the status itself and passes the body to one refusal handler. The waits use setTimeout from node:timers/promises. [7][5]

What it prints

The example below has illustrative values; the finding titles and fixes are the report's own wording for those checks. The first line can read completed instead of queued when the API returns a report you already have. [1]

node recipe.mjs https://example.comtext
job 3f6c2a1e-8d4b-4c7a-9e21-5b0d7f4a6c18: queued
https://example.com: 64/100 (mixed)
open issues: 9, pages analysed: 24

#1 [high] Canonical tag missing (URLs & Redirects)
   Fix: Add a canonical link element or Link HTTP header pointing to the preferred URL for this page. This is a strong canonicalization signal that helps search systems understand which version represents duplicate or very similar content; the declared URL remains a preference, not a guarantee.

#2 [high] Meta description missing (On-Page SEO)
   Fix: Add a concise, page-specific meta description that accurately describes the visible content. Search systems may use it for a result snippet when it is more helpful than text taken from the page.

#3 [high] Render parity mismatch (Crawl & Index)
   Fix: Ensure raw HTML and rendered DOM contain the same critical SEO elements (title, H1, canonical, meta description, structured data). If content is injected by JavaScript, move it to server-side rendering so crawlers see it immediately.

full diagnosis saved to seoreport-3f6c2a1e-8d4b-4c7a-9e21-5b0d7f4a6c18.json

The export lists every finding in fix order, passed and informational results included, so the script keeps the failing and warning ones and prints the first three. The saved JSON also holds each area's score, open issues by severity, and for every finding why it matters, how to verify the fix, and the evidence and pages behind it. [1]

Handling refusals and limits

A refusal is JSON with success set to false and an error sentence, and the refusals listed under Errors and limits on the developers page also carry a machine-readable code. The script prints the code, or the HTTP status when there is none, adds the plan or settings link when the API sends one, and exits with status 1 instead of retrying. [1]

402 PAYMENT_REQUIRED means the request did not reach a paid plan: the key is missing, revoked or mistyped, or the account has no active plan. POST /api/v1/reports treats a key it does not recognise as no account at all, so it answers 402, not 401. 402 REPORT_CREDITS_EXHAUSTED means this month's credits are spent. [1][2]

429 FRESH_RUN_LIMIT applies only when you add "forceRerun": true to scan a site again instead of reusing its report; the API's own message caps fresh scans at 30 an hour per account and sends a Retry-After header, which the script prints. These recipes never set forceRerun. [1][2]

A report that ends failed or dead_lettered carries an error object when its cause was recorded: a code such as DNS_FAILURE or CRAWLER_BLOCKED, a message, and retryable, which says whether running it again can help. cancelled ends the run too. A 409 with errorCode ZOMBIE_JOB_DETECTED means the report could not be completed because of a synchronization issue: start a new report. [1]

Next steps

For an AI agent, skip the script: point any MCP-capable client at https://seoreport.dev/mcp with the same Bearer key, and its report tools take the same steps: start a report, poll it until it is ready, read the findings and get a download link. [1]

For a list of sites, call the script once per URL, one at a time: each new site spends a credit and a site you already have is reused for free. To re-audit the same domains every week, register them with PUT /api/v1/monitors instead; monitoring is included with paid plans and does not spend report credits. [1]

The same download endpoint also returns the report as Markdown, text or PDF; the API reference lists the endpoints and documents the result fields. [2]

Every endpoint, the MCP server and the plans are on the SEO report API page. API and MCP access come with every paid plan.

Sources

  1. SEOReport: SEO Report API for developers and AI agents, verified on 2026-10-18
  2. SEOReport: API and MCP reference, verified on 2026-10-18
  3. Node.js docs: Globals, fetch and AbortSignal.timeout, verified on 2026-10-18
  4. Node.js docs: ECMAScript modules, verified on 2026-10-18
  5. Node.js docs: Timers promises API, verified on 2026-10-18
  6. Node.js docs: process.env, verified on 2026-10-18
  7. MDN: fetch(), the reference Node.js links for its fetch, verified on 2026-10-18

Get the complete diagnosis of your site

An evidence-backed report and a prioritized action plan, on a plan with monthly credits.