docs / audit api

Audit API

One POST starts an audit of one public page and hands back a token. One GET reads the report with it. That is the whole flow — the rest of this page is detail, errors and limits.

Authentication, in one paragraph

Starting an audit returns a session token in the response body. Send it back as Authorization: Bearer <token> to read the report. It lasts seven days and is the only thing that proves a report is yours — there are no accounts and no separate API keys. Nothing else is needed: no cookie jar, no key you have to invent, no call before the call.

One caveat, on the first request only, before any token exists: send no Origin and no Sec-Fetch-* header. With neither a token nor a same-origin session those mark the call as a browser’s, and it is refused with ORIGIN_REJECTED — which is what stops a web page spending a visitor’s audit allowance. Command-line and server-side clients send neither by default. Once you hold a token the headers stop mattering.

Start to finish

two requests and a poll
curl -sS -X POST https://geo.new/api/v1/audits \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'

# → 202 {"audit_id":"aud_…","session":"…","markdown_url":"…","poll_after_seconds":2}

curl -sS -H "Authorization: Bearer $SESSION" "$MARKDOWN_URL"

# → 202 while it fills, 200 once X-Audit-Status is complete, partial or failed

Start an audit

no token needed

POST/api/v1/audits

Admits one audit of one public page, or attaches to a run already in flight for that URL, and hands back the token you read it with.

Content-Typeapplication/json — anything else is 415
Body{"url": string, "fresh"?: boolean} — strict; unknown keys are rejected
Authorizationoptional: Bearer <session> to reuse a session you already hold
Max body8 KiB
202Admitted or attached. Retry-After: 2. Location: /api/v1/audits/{id}
200A finished report for that URL was reused. cached: true
400INVALID_URL, INVALID_JSON, INVALID_REQUEST, INVALID_IDEMPOTENCY_KEY
409IDEMPOTENCY_CONFLICT — same key, different body
429GLOBAL_RATE_LIMITED, PRINCIPAL_RATE_LIMITED, TARGET_COOLDOWN
503CAPACITY_BUSY — no audit was admitted; retry
curl -sS -X POST https://geo.new/api/v1/audits \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'
  • The envelope carries session, audit_id, status, revision, cached, report_url, snapshot_url, markdown_url and poll_after_seconds. Keep session: it is how you read the report back, and how you reach it again tomorrow.
  • Send the same URL twice on one session and you attach to the run already in flight, or reuse a finished one under an hour old. Duplicate requests do not duplicate work, and you need no key to get that.
  • Idempotency-Key is optional, 16–128 printable characters, and only earns its place on fresh: true, where that reuse is skipped. It cannot be issued by this API — it only works if it stays the same across your retries — so a client library should generate one per call and you should never have to type one.
  • fresh: true skips reuse and is what TARGET_COOLDOWN limits to one run per URL per five minutes.

Read the snapshot

token required

GET/api/v1/audits/{id}

The whole report as saved: scores, findings, evidence, crawler policy and page inventory.

AuthorizationBearer <session> — required
Acceptapplication/json (default) or text/markdown
200The snapshot at its current revision, running or finished
404REPORT_UNAVAILABLE — missing, expired, revoked, or another session’s
406NOT_ACCEPTABLE — the Accept header asks for something else
curl -sS -H "Authorization: Bearer $SESSION" \
  https://geo.new/api/v1/audits/$AUDIT_ID
  • Always 200 while running, so read status, not the status code. It is one of queued, fetching, analyzing, writing, complete, partial or failed.
  • scores.catalog carries the single GEO score: six categories with subcategories, platforms, pillars and blockers. scores.geo and scores.seo are the retained v1 summaries.
  • GET never starts work and never triggers a model call. Poll it every two seconds until the status is terminal.
  • The browser app authenticates with its httpOnly geo_session cookie instead, which is why that cookie exists; a script should not have to keep a jar.

Read the Markdown report

token required

GET/api/v1/audits/{id}/report.md

The same report projected to Markdown, for pasting into a model or a pull request.

AuthorizationBearer <session> — required
Accepttext/markdown or */*
202Still running. Retry-After: 2. The body holds the findings so far
200Terminal — including a failed report
404REPORT_UNAVAILABLE
curl -sS -H "Authorization: Bearer $SESSION" \
  https://geo.new/api/v1/audits/$AUDIT_ID/report.md
  • Unlike the snapshot, this one does answer 202 while the report fills, so the status code is a usable signal here.
  • X-Audit-Revision and X-Audit-Status describe the exact saved revision in the body you received.
  • 200 means the report is finished, not that the page passed. Read X-Audit-Status.

Open a session first

no token needed

POST/api/v1/session

Mints a token on its own, for when you want the credential in hand before a request you cannot afford to send twice.

Bodynone
Headersno Origin, no Sec-Fetch-*
200{"session":"<token>","expires_in_seconds":604800}
429PRINCIPAL_RATE_LIMITED — ten new sessions per IP per hour
curl -sS -X POST https://geo.new/api/v1/session
  • Optional, and listed last for a reason: starting an audit mints a session too, so the ordinary flow never calls this.
  • It earns its place in one case. Idempotency records are stored against the session that made them, so if a create request times out before its token reaches you, the retry authenticates as somebody new and cannot bind to the original — you would start a second audit and lose the first. Holding a token beforehand removes that.
  • The token lasts seven days and is the only thing that proves a report is yours. There are no accounts and no separate API keys.

Errors

every failure, one shape
{"error":{"code":"TARGET_COOLDOWN",
           "message":"This URL was recently audited. Wait five minutes between fresh runs.",
           "retryable":true,
           "request_id":"req_4c1f…",
           "retry_after_seconds":300}}
400INVALID_URLThe url field is not a valid HTTP or HTTPS address.
400INVALID_JSONThe body did not parse as JSON.
400INVALID_REQUESTThe body parsed but is not {url, fresh?}.
400INVALID_IDEMPOTENCY_KEYThe key is outside 16–128 printable characters.
403ORIGIN_REJECTEDAn Origin header and no bearer token, so the call was read as a browser’s.
403SESSION_REQUIREDSec-Fetch-* headers and no bearer token, so the call was read as a browser’s.
404REPORT_UNAVAILABLEMissing, expired, revoked, or read without the session that made it — deliberately indistinguishable.
406NOT_ACCEPTABLEThe Accept header asks for a representation this route does not serve.
409IDEMPOTENCY_CONFLICTThat Idempotency-Key was already used with a different body.
413BODY_TOO_LARGEThe request body is over 8 KiB.
415JSON_REQUIREDContent-Type was not application/json.
429REQUEST_RATE_LIMITEDToo many requests of any kind. 180 per session per minute, 600 per IP.
429PRINCIPAL_RATE_LIMITEDYour own audit or session allowance is full.
429GLOBAL_RATE_LIMITEDThe allowance shared by every caller is full.
429TARGET_COOLDOWNThis URL was audited within the last five minutes.
503CAPACITY_BUSYNo worker slot was free. Nothing was admitted; retry.
503SERVICE_UNAVAILABLEAn unexpected failure, reported without detail.

Amber codes are retryable and carry Retry-After; wait it out rather than backing off blindly. Every response carries request_id — quote it if you report a problem.

Limits

anonymous allowances
Audit starts, everyone1,000 per rolling 5 minutes · 10,000 per hourGLOBAL_RATE_LIMITED
Audit starts, per session5 per hourPRINCIPAL_RATE_LIMITED
Audit starts, per IP10 per hourPRINCIPAL_RATE_LIMITED
Fresh runs, per URL1 per 5 minutesTARGET_COOLDOWN
Requests, per session180 per minuteREQUEST_RATE_LIMITED
Requests, per IP600 per minuteREQUEST_RATE_LIMITED
New sessions, per IP10 per hourPRINCIPAL_RATE_LIMITED

Reusing a cached report spends a request, not an audit start. Keys with higher personal allowances are planned; they will still share the ceiling everyone shares.

Outside the contract

These routes exist because the web app uses them. They need a CSRF token, they are not in the OpenAPI contract, and they can change without notice. Do not build on them.

POST /api/session · GET /api/audits/:id/events · POST /api/audits/:id/rerun

POST /api/audits/:id/share · DELETE /api/audits/:id/shares · GET /api/shares/:token

GET /api/audits/:id/image · GET /api/audits/:id/baselines · GET /api/audits/:id/compare/:baseline

Reports are private, expire after seven days, and are served no-store.Never audit a URL that carries a secret in its query string.