Skip to main content

Website Audit API

Start an audit with one POST, poll until it finishes, and get back the same versioned JSON schema the app exports. Available on the Agency plan.

The REST API runs the same analyzers the app does — 18 of them across SEO, security, accessibility, AI readiness, CSS, and hosting, with PageSpeed optional — and hands back the result as versioned JSON instead of a page. It is how you put audits inside your own onboarding flow, monitoring job, or client dashboard.

API access is an Agency-plan feature. Everything below is the public reference; creating and revoking keys happens on the API access page once you are signed in.

Authentication

Every request carries an API key in the Authorization header. Keys are created on the API access page; an Agency account can hold up to 5 active keys at a time.

Authorization: Bearer was_your_api_key_here

A key is the string was_ followed by 27 URL-safe characters, 31 in total. Only its hash is stored, so a key is shown once at creation and cannot be recovered afterwards — revoke and create a new one instead. Anything that is not a well-formed, active key belonging to an account with a live subscription is rejected with a 401 before any work is done.

Base URL

https://webauditsuite.com/api/v1

Endpoints

Audits are asynchronous. You start one with a POST, which returns immediately with an id, then poll a GET until the status stops being processing. A full audit normally takes 10 to 30 seconds, or up to about two minutes when you ask for PageSpeed data.

POST

/api/v1/audit

Starts an audit and returns 202 Accepted with the id you poll. It does not return results.

Request Body

{
  "url": "https://example.com",
  "include_psi": false
}
  • url — required. A public http or https URL, 2048 characters or fewer. Addresses that resolve to private, loopback, or cloud-metadata ranges are refused.
  • include_psi — optional, defaults to false. When true the audit also runs Google PageSpeed Insights, which adds up to 90 seconds. When it is left off, the pagespeed tab comes back empty with an unrunState rather than missing.

Response (202)

{
  "id": "audit_1785086404956_k3f9qa",
  "status": "processing",
  "_links": {
    "self": "/api/v1/audit/audit_1785086404956_k3f9qa",
    "poll": "/api/v1/audit/audit_1785086404956_k3f9qa"
  }
}

self and poll are the same URL today — follow poll, so a future split does not break you.

GET

/api/v1/audit/{id}

Retrieves an audit by id. Results are kept for one hour after the audit completes, then the id returns NOT_FOUND. Polling does not consume any rate limit.

While it runs

{
  "id": "audit_1785086404956_k3f9qa",
  "status": "processing",
  "_links": {
    "self": "/api/v1/audit/audit_1785086404956_k3f9qa",
    "poll": "/api/v1/audit/audit_1785086404956_k3f9qa"
  }
}

When it finishes

Abridged below — the eight tabs and their cards are elided, everything else is a real response for https://example.com. That site sends no security headers at all, which is why its security score really is 0.

{
  "id": "audit_1785086404956_k3f9qa",
  "status": "complete",
  "schemaVersion": "2.2.0",
  "exportedAt": "2026-07-26T17:20:04.957Z",
  "metadata": {
    "url": "https://example.com",
    "analyzedAt": "2026-07-26T17:20:04.956Z",
    "psiCompleted": false,
    "userAuthenticated": true
  },
  "scores": {
    "accessibility": {
      "value": 70, "locked": true,
      "wcagLevel": "No issues detected at A or AA (automated check)"
    },
    "geo": { "value": 20, "locked": true },
    "security": { "value": 0, "locked": true },
    "css": { "value": 100, "locked": true },
    "seo": {
      "compositeScore": {
        "value": 54, "maxPossible": 100, "percentage": 54, "locked": false
      }
    },
    "brokenLinks": { "value": 100, "locked": false },
    "redirects": { "value": 100, "locked": false },
    "canonicals": { "value": 95, "locked": false },
    "metadata": { "value": 83, "locked": false },
    "sitemap": { "value": 50, "locked": false }
  },
  "tabs": {
    "overview":      { "enabled": true, "order": 1, "cards": { ... } },
    "pagespeed":     { "enabled": true, "order": 2, "locked": false, "cards": {},
                       "unrunState": { "show": true,
                                       "message": "PageSpeed Insights has not been run yet" } },
    "accessibility": { "enabled": true, "order": 3, "cards": { ... } },
    "seo":           { "enabled": true, "order": 4, "cards": { ... } },
    "geo":           { "enabled": true, "order": 5, "cards": { ... } },
    "security":      { "enabled": true, "order": 6, "cards": { ... } },
    "css":           { "enabled": true, "order": 7, "cards": { ... } },
    "hosting":       { "enabled": true, "order": 8, "cards": { ... } }
  },
  "_links": {
    "self": "/api/v1/audit/audit_1785086404956_k3f9qa",
    "expiresAt": "2026-07-26T18:20:04.958Z"
  }
}

When it fails

A failed audit is still HTTP 200 — the failure is in the body, not the status code. An audit still processing five minutes after it started is reported the same way, with "Audit timed out".

{
  "id": "audit_1785086404956_k3f9qa",
  "status": "failed",
  "error": "Failed to fetch the target URL. The site may be unreachable, blocking automated requests, or timing out."
}
GET

/api/v1/usage

Current usage and remaining quota for the key that made the request. Like polling, this endpoint does not consume any rate limit.

{
  "key": {
    "id": "8f1c0d2e-4a55-4a1b-9f3c-6d0e2b7a91c4",
    "name": "Production Server",
    "prefix": "was_9tKq3bXd",
    "createdAt": "2026-07-01T09:12:44.000Z",
    "lastUsedAt": "2026-07-26T17:20:04.000Z",
    "totalRequests": 42
  },
  "tier": "agency",
  "limits": {
    "burst": { "limit": 2, "used": 1, "remaining": 1, "resetsIn": "1 minute" },
    "daily": { "limit": 25, "used": 12, "remaining": 13, "resetsAt": "2026-07-27T00:00:00.000Z" },
    "monthly": { "limit": 100, "used": 42, "remaining": 58, "resetsAt": "2026-08-01T00:00:00.000Z" }
  }
}

Code Examples

The same start-then-poll flow in three languages. Poll on an interval you are comfortable with — reads are free — and always branch on status before reading scores. The shell samples read the key from $WAS_API_KEY; keep it in your environment rather than in shell history or a committed file.

cURL

# 1. Start the audit — returns 202 with an id
curl -X POST https://webauditsuite.com/api/v1/audit \
  -H "Authorization: Bearer $WAS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

# 2. Poll until status stops being "processing"
curl https://webauditsuite.com/api/v1/audit/audit_1785086404956_k3f9qa \
  -H "Authorization: Bearer $WAS_API_KEY"

JavaScript

const API = 'https://webauditsuite.com/api/v1';
const auth = { Authorization: 'Bearer was_your_api_key' };

const started = await fetch(`${API}/audit`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({ url: 'https://example.com' }),
});
const { id } = await started.json(); // 202, status: "processing"

let audit;
do {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  audit = await fetch(`${API}/audit/${id}`, { headers: auth }).then((r) => r.json());
} while (audit.status === 'processing');

if (audit.status === 'failed') throw new Error(audit.error);
console.log(audit.scores);

Python

import time
import requests

API = 'https://webauditsuite.com/api/v1'
auth = {'Authorization': 'Bearer was_your_api_key'}

started = requests.post(
    f'{API}/audit',
    headers=auth,
    json={'url': 'https://example.com'},
)
audit_id = started.json()['id']  # 202, status: "processing"

while True:
    time.sleep(5)
    audit = requests.get(f'{API}/audit/{audit_id}', headers=auth).json()
    if audit['status'] != 'processing':
        break

if audit['status'] == 'failed':
    raise RuntimeError(audit['error'])

print(audit['scores'])

Rate Limits

Only POST /api/v1/audit is metered. Reading an audit or checking usage is free, so polling costs you nothing. The three limits below are counted per key: a second key gets its own burst, daily and monthly counters, and the limits a key is created with stay with that key.

API rate limits, per key
Limit TypePer Key (Agency)Reset
Burst2 requests/minuteEvery minute
Daily25 requests/dayMidnight UTC
Monthly100 requests/month1st of month UTC

Every audit request counts toward the daily and monthly limits — including requests we reject for an invalid body, an unsupported URL, or rate limiting. An integration that retries on error will use up its allowance, so handle error responses rather than looping on them.

A completed API audit is also written to your scan history, so it draws on the same monthly allowance as audits you run in the app — 100 in total on Agency. Exhausting it returns QUOTA_EXCEEDED even when the per-key counters still have room.

Errors

Errors return a consistent JSON structure. error is the human-readable message and code is the stable identifier — branch on code, never on the message text.

{
  "error": "Daily limit exceeded. Resets at midnight UTC.",
  "code": "DAILY_LIMITED",
  "remaining": { "burst": 1, "daily": 0, "monthly": 58 },
  "resetAt": "2026-07-27T00:00:00.000Z"
}

remaining appears on all three per-key rate-limit rejections (BURST_LIMITED, DAILY_LIMITED, MONTHLY_LIMITED); resetAt appears on the first two only, since the monthly counter has no reset timestamp to report — with one exception, a BURST_LIMITED raised because our rate-limit store is unavailable and we fail closed, which carries no resetAt either. details appears when a request body failed validation. Those three rejections also carry Retry-After (60 seconds for a burst rejection, 3600 otherwise) and X-RateLimit-Remaining, which a successful 202 carries too. QUOTA_EXCEEDED is a 429 as well, but it comes from your monthly scan allowance rather than a per-key counter, so it carries none of those fields or headers.

API Error Codes
CodeHTTPMessage
INVALID_KEY401Invalid API key
KEY_REVOKED401API key has been revoked
KEY_EXPIRED401API key has expired
SUBSCRIPTION_INACTIVE401Subscription is not active
INVALID_REQUEST400Invalid request format
INVALID_URL400Invalid URL provided
NOT_FOUND404Resource not found
BURST_LIMITED429Too many requests. Please wait a moment.
DAILY_LIMITED429Daily limit exceeded. Resets at midnight UTC.
MONTHLY_LIMITED429Monthly limit exceeded. Upgrade for more requests.
QUOTA_EXCEEDED429Monthly scan quota exceeded. Upgrade your plan for more scans.
INTERNAL_ERROR500Internal server error

An audit that fails after it starts never appears here: GET /api/v1/audit/{id} returns HTTP 200 with status: "failed" and a plain-English error string.

Response Schema & Versioning

A completed audit carries schemaVersion — currently 2.2.0 — using the same versioned structure as the JSON export you can download from the app. Check that field first. New optional fields arrive in minor bumps and existing fields keep their meaning, so a consumer written against any response with the same major version keeps working; a major bump is the only thing that can remove or rename a field.

Three releases so far ask anything of a consumer:

Schema changelog: releases that change existing behaviour
VersionReleasedChange
2.2.0August 2026canonicalTagsCard gains three optional fields: nonSelfReferential lists same-origin canonicals whose target is not the page itself, sitemapMismatches narrows that to targets your sitemap does not list, and sitemapComparisonSkipped appears when that comparison could not run. All three are informational — no score changes. Absent on scans captured before this release; [] means checked and clean.
2.1.1August 2026technicalSeoCategories now carries the composite SEO breakdown instead of an empty array. Each entry is name, score, maxScore and status; a full scan returns eight categories that sum to the compositeScore alongside them. Nothing was added or renamed — the field was documented from the start and had never been populated.
2.1.0August 2026Readability is now measured on visible prose. wordCount, fleschScore, fleschGrade and textToHtmlRatio keep their names, types and positions, but the text behind them no longer includes <script>, <style>, <noscript> or <template> content. Expect large drops on script-heavy pages — a single-page app's hydration payload was being counted as page copy. Word count now reflects what a reader sees.
2.0.0August 2026Removed the AEO composite score, tabs.geo.cards.geoScoreCard.aeoInsights.overallScore. The signals it was built from stay exactly where they were, now as an unscored checklist you can read or weight yourself.
1.8.0August 2026wcagLevel keeps its name, type and position, but its values changed from conformance levels such as AA and AAA to automated-observation strings such as No issues detected at A or AA (automated check). Treat it as display text rather than string-matching it. The per-issue wcagLevel inside issues is unchanged and still machine-readable.

No release before 1.8.0 removed or renamed a field. They added optional ones, with a single value-level exception: 1.7.0 moved amazonbot into the training category to match Amazon's own documentation. That category is presentation only and has never been scored.

There has been one major bump. 2.0.0 (August 2026) removed exactly one field, tabs.geo.cards.geoScoreCard.aeoInsights.overallScore: it was a 0-100 gauge whose two heaviest weights sat on FAQ and HowTo schema, and we could not point you at anything tying either to being cited by an AI answer engine. The individual signals it was built from are all still there and unchanged. Nothing else was removed or renamed, so the migration from 1.x is to stop reading that one field.

scores holds every computed score, and tabs holds the eight result tabs — overview, pagespeed, accessibility, seo, geo, security, css, and hosting — each at least an { enabled, order, cards } object whose cards hold the detailed findings. Website audit scores explained covers what each number measures, and reports and exports covers the export the API mirrors.

Two differences from a downloaded export, worth knowing before you write a shared parser: the API adds id, status, and _links around the audit, and it does not return the optional aiSuggestions block a download can include. A completed audit response carries exactly schemaVersion, exportedAt, metadata, scores, and tabs — plus failures when at least one analyzer did not complete, the same way the download carries it.

Next Steps