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": "1.6.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": "Partial A" },
    "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.

API Rate Limits
Limit TypeAgency TierReset
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 and resetAt appear on rate-limit rejections; details appears when a request body failed validation. Those responses also carry Retry-After (60 seconds for a burst rejection, 3600 otherwise) and X-RateLimit-Remaining, which a successful 202 carries too.

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 1.6.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 1.x response keeps working; a major bump is the only thing that can remove or rename a 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 or failures blocks a download can include. A completed audit response carries exactly schemaVersion, exportedAt, metadata, scores, and tabs.

Next Steps