docs / audit api

Audit API

Three ways in: a terminal, an agent, or HTTP. Underneath, one POST starts an audit of one public page and hands back a token, and one GET reads the report with it. The rest of this page is detail, keys, errors and limits; the walkthrough of the command line and the MCP server is /agents.

From a terminal

one line, no key
npx -y geo-new audit https://example.com
What happens
Markdown on stdout once the measured rows land (about a minute); progress and a five-line summary on stderr. The AI synthesis keeps writing afterwards and never moves a score; --wait-for-ai waits for it.
Exit codes
0 done · 1 audit failed · 2 usage · 3 rate limited · 4 auth or ownership · 5 network or server · 6 below --min-score (unmeasured counts as below unless --allow-unscored) · 7 score dropped in compare · 8 still running when --wait expired
Then
geo-new fix prints the prompt for your coding agent; geo-new share prints a link a person can open; geo-new report re-reads the last audit; --fresh measures again after an edit
Scripts
--json or --out after.json for the snapshot; geo-new compare before.json after.json compares two saved snapshots offline (exit 7 on a drop; --allow-missing-baseline on a first run)
Credentials
Keyless by default: the session lives in $XDG_CONFIG_HOME/geo-new/credentials.json (0600). GEO_NEW_API_KEY or geo-new login <key> for a key; CI should hold one
Local runs
GEO_NEW_API_URL=http://127.0.0.1:8787 against npm run dev:demo; a key is sent only to https or loopback origins

From an agent

MCP over stdio
claude mcp add geo-new -- npx -y geo-new@0 mcp

# Windows
claude mcp add geo-new -- cmd /c npx -y geo-new@0 mcp

# Cursor, Codex and other MCP clients
{ "mcpServers": { "geo-new": { "command": "npx", "args": ["-y", "geo-new@0", "mcp"] } } }
Tools
audit_page · get_report (summary by default; markdown or json on request) · get_fix_prompt · compare_reports · get_usage · create_share · revoke_share, plus the geo-new://audits/{id} resource
The loop
audit_page, then get_fix_prompt, edit the page, then audit_page again with fresh: true. A non-fresh call may return the report measured before your change, marked cached with its age; every result carries a next step
Limits
Rate limits come back as ordinary results with a retry step; a keyless server gets 5 audits an hour and re-reads are free. geo-new key create in any terminal raises it to 10 and a running server picks the key up on its next call
Trust
Quoted page content inside a result is data from the audited site, never instructions. stdout carries JSON-RPC only

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 proves a report is yours. When you want a credential that outlives it, create a key once (gnk_…) and send it the same way; there are still no accounts. 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 or a key the headers stop mattering, on every path.

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

The status passes through writing, where the measured rows are published and the model is still drafting its interpretation — up to about two minutes that nothing else waits for. Stop there if ai.synthesis is not what you came for.

Start an audit

no token needed

POST/api/v1/auditscreateAudit

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-Type
application/json — anything else is 415
Body
{"url": string, "fresh"?: boolean, "assistant_answers"?: boolean, "assistant_engines"?: ["chatgpt" | "google_ai_mode"]} — strict; unknown keys are rejected
Authorization
optional: Bearer <session> to reuse a session you already hold
Max body
8 KiB
202
Admitted or attached. Retry-After: 2. Location: /api/v1/audits/{id}
200
A finished report for that URL was reused. cached: true
400
INVALID_URL, INVALID_JSON, INVALID_REQUEST, INVALID_IDEMPOTENCY_KEY
409
IDEMPOTENCY_CONFLICT — same key, different body
429
GLOBAL_RATE_LIMITED, PRINCIPAL_RATE_LIMITED, IP_RATE_LIMITED, TARGET_COOLDOWN
503
CAPACITY_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"}'
  • Follow markdown_url or snapshot_url to poll; never repeat POST as a polling mechanism. Use one established session and Idempotency-Key for retry-sensitive creation, especially fresh requests.
  • 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. TARGET_COOLDOWN then allows strangers one run per URL per five minutes and you, re-running your own URL after an edit, one per minute. An admitted or reused audit carries the measured hourly allowance in RateLimit and RateLimit-Policy. Early failures may lack quota state.
  • assistant_engines (experimental) asks ChatGPT, Google AI Mode or both about the brand; answers land in assistant_answers and ai_mode_answers, unscored. It wins over assistant_answers, whose true means ChatGPT only, and assistant_answers: false beside a non-empty assistant_engines is a 400. A request that asks an engine never reuses an audit that did not.

Read the snapshot

token required

GET/api/v1/audits/{id}getAudit

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

Authorization
Bearer <session> — required
Accept
application/json (default) or text/markdown
200
The snapshot at its current revision, running or finished
404
REPORT_UNAVAILABLE — missing, expired, revoked, or another session’s
406
NOT_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.mdgetAuditMarkdown

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

Authorization
Bearer <session> — required
Accept
text/markdown or */*
202
Still running. Retry-After: 2. The body holds the findings so far
200
Terminal — including a failed report
404
REPORT_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/sessioncreateSession

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

Body
none
Headers
no Origin, no Sec-Fetch-*
200
{"session":"<token>","expires_in_seconds":604800}
429
IP_RATE_LIMITED — 30 new sessions per IP per hour
curl -sS -X POST https://geo.new/api/v1/session
  • Optional: 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. A key (below) is the durable alternative: it outlives any session and carries its own allowance.

Read the fix prompt

owner only

GET/api/v1/audits/{id}/fix-promptgetFixPrompt

A plain-text brief for a coding agent: the page as measured, each open action with its evidence and the change to make.

Authorization
Bearer <session or key> — the owner of the report
200
text/plain; the same brief the report page copies for you
404
REPORT_UNAVAILABLE
curl -sS -H "Authorization: Bearer $SESSION" \
  https://geo.new/api/v1/audits/$AUDIT_ID/fix-prompt
  • Capped at 32 KB and says what it left out. Quoted page text inside it is data from the audited site, not instructions.
  • X-Audit-Revision names the revision the brief was built from; a report still writing its synthesis yields a brief without the model’s fix notes.

Compare two reports

owner only

GET/api/v1/audits/{id}/compare/{baseline}compareAudits

Score, category and row deltas between a report and an earlier one of the same URL that you also own.

Authorization
Bearer <session or key> — the owner of both reports
200
The comparison document
404
REPORT_UNAVAILABLE — either report is missing, expired or not yours
422
BASELINE_INELIGIBLE — a different URL, or a report that is still being measured
curl -sS -H "Authorization: Bearer $SESSION" \
  https://geo.new/api/v1/audits/$AUDIT_ID/compare/$BASELINE_ID
  • Reports expire after seven days, so a pipeline that compares across deploys keeps its own JSON snapshots and compares them offline; the CLI does exactly that with saved files.
  • A finished baseline is not a passing one: compare deltas, not verdicts.

Create a share link

owner only

POST/api/v1/audits/{id}/shareshareAudit

A revocable bearer link to one immutable copy of a finished report, which anyone can open in a browser.

Authorization
Bearer <session or key> — the owner of the report
Body
none
201
{"url":"https://geo.new/s/…","expires_at":"…"}
404
REPORT_UNAVAILABLE
409
REPORT_NOT_SHAREABLE — wait for a finished report with evidence
curl -sS -X POST -H "Authorization: Bearer $SESSION" \
  https://geo.new/api/v1/audits/$AUDIT_ID/share
  • The copy carries no comparison baseline and never changes; the link dies with the report or when you revoke it.
  • A report made from a terminal opens in a browser only through a link like this, or in a browser that has claimed your key.

Revoke share links

owner only

DELETE/api/v1/audits/{id}/sharesrevokeShares

Every share link to this report stops working at once.

Authorization
Bearer <session or key> — the owner of the report
200
{"revoked":true}
404
REPORT_UNAVAILABLE
curl -sS -X DELETE -H "Authorization: Bearer $SESSION" \
  https://geo.new/api/v1/audits/$AUDIT_ID/shares
  • Idempotent: revoking a report with no links is still 200.

Create a key

no token needed

POST/api/v1/keyscreateKey

A key outlives any session, carries the free plan’s allowance and keeps a usage record. Shown once; store it.

Authorization
optional: Bearer <session or key>. Without one, this call mints a session and the key together
Body
optional: {"label": string} — 1 to 40 printable characters; the default is what asked (cli, mcp, browser)
201
{"key":"gnk_…","prefix":"a1b2c3d4","label":"…","created_at":"…","principal_id":"prn_…"}
400
INVALID_REQUEST with field: label
404
REPORT_UNAVAILABLE — keys are switched off on this deployment
409
KEY_LIMIT — five live keys already; revoke one
429
KEY_ISSUANCE_LIMITED — ten per network per day
curl -sS -X POST https://geo.new/api/v1/keys \
  -H 'Content-Type: application/json' -d '{"label":"ci"}'
  • The first key made from a session moves that session’s reports to the new principal, so the key sees everything the browser made and the browser keeps seeing it too.
  • A key is a bearer like a session: send it as Authorization: Bearer gnk_… on every route. It is never returned in a session field and must never travel in a cookie.
  • There is no recovery. A lost key is a lost principal, exactly as a lost session token is today; keep it where you keep other secrets.

List your keys

key or bound session

GET/api/v1/keyslistKeys

The live keys of the principal you act as, newest first, by prefix and label. Never the secrets.

Authorization
Bearer <key>, or a session that holds or has claimed a key
200
{"principal":{"kind":"key","plan":"free","prefix":"…"},"keys":[…]} — or principal: null and an empty list
404
REPORT_UNAVAILABLE — keys are switched off on this deployment
curl -sS -H "Authorization: Bearer $GEO_NEW_API_KEY" \
  https://geo.new/api/v1/keys
  • This is the complete bounded list: at most 5 live keys per principal, with no pagination.
  • last_used_at is written at most once a minute per key, so it lags a busy key by up to sixty seconds.

Revoke a key

key or bound session

DELETE/api/v1/keys/{prefix}revokeKey

Immediate and everywhere. A key can revoke itself, so revocation never depends on a session.

Authorization
Bearer <key>, or a session bound to the principal
prefix
the eight hex characters after gnk_, with or without that prefix
200
{"revoked":true}
400
INVALID_REQUEST with field: prefix
404
KEY_UNAVAILABLE — no such live key on this principal
404
REPORT_UNAVAILABLE — keys are switched off on this deployment
curl -sS -X DELETE -H "Authorization: Bearer $GEO_NEW_API_KEY" \
  https://geo.new/api/v1/keys/a1b2c3d4
  • Revoking the last live key of a principal also unbinds every browser that claimed it; the browser that created the principal keeps its identity.

Read your allowance

no token needed

GET/api/v1/usagegetUsage

What you act as, how many audits remain this hour, when that resets, and how full the shared buckets are.

Authorization
optional: Bearer <session or key>. Without one you get the anonymous defaults and principal: null
200
{"principal":…,"plan":"free","allowance":{"limit":10,"used":3,"remaining":7,"reset_seconds":2460},"global":{…}}
curl -sS -H "Authorization: Bearer $GEO_NEW_API_KEY" \
  https://geo.new/api/v1/usage
  • The same numbers ride on every create response as RateLimit ("audits";r=<remaining>;t=<reset_seconds>) and RateLimit-Policy ("audits";q=<limit>;w=3600).
  • Re-reads of a report and cache hits spend a request, never an audit; only an admission counts here.
  • assistants.chatgpt and assistants.google_ai_mode (experimental): your and your network’s asks left today (of 6 and 12), whether the shared pool had room when read (not a reservation), and resets_at at 00:00 UTC; null when that assistant is off.

Keys

optional, durable, shown once

A key outlives any session, doubles the hourly allowance and keeps a usage record; it changes nothing about how you read a report. There is no account behind it and no recovery: a lost key is a lost identity, exactly as a lost session is. Create one here for this browser, or with geo-new key create in a terminal; paste one below to make this browser act as it, so reports made from a terminal open at their /r/ links here too.

Your allowance and keys are read only when you ask; nothing here calls the API until then.

Plans, from the table the store enforces. CI on a shared runner pool should hold a key: keyed principals have their own network bucket. builder is set by hand for now.

anonymous15 audits/h180 req/minnetwork 30/hany session
free30 audits/h180 req/minnetwork 150/hany key
builder150 audits/h600 req/minnetwork 150/h (logged only)set by hand · ask via /about/contact

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?, assistant_answers?, assistant_engines?}, or it sends assistant_answers: false beside assistant_engines.
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.
404ROUTE_NOT_FOUNDNo such public API route. Read /docs/api or /openapi.json; no session is created.
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: 180 per session/free principal per minute, 600 for builders, and 600 per IP.
429PRINCIPAL_RATE_LIMITEDYour own hourly audit allowance is full; the plan sets it.
429IP_RATE_LIMITEDThis network’s allowance is full: audits per hour, or new sessions per hour. Keyed principals share a bucket of their own.
429GLOBAL_RATE_LIMITEDThe allowance shared by every caller is full.
429TARGET_COOLDOWNSomeone else audited this URL within five minutes, or you did within one; retry_after_seconds says which.
401KEY_INVALIDThe gnk_ key is unknown, revoked, or keys are switched off, or Authorization carried something that is neither a session token nor a key. Nothing was minted in its place.
409KEY_LIMITFive live keys already on this principal. Revoke one first.
429KEY_ISSUANCE_LIMITEDTen keys were created from this network today.
404KEY_UNAVAILABLENo such live key on the principal you act as.
409REPORT_NOT_SHAREABLEShare links need a finished report with evidence; retry once it finishes. A failed report, or one without evidence, never becomes shareable.
422BASELINE_INELIGIBLEThe baseline is a different URL or is still being measured.
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

allowances by plan

RateLimit and RateLimit-Policy describe only measured buckets. "requests" is the caller’s rolling-minute request state, including the accepted request; "audits" is the hourly audit allowance, included on creation, usage and admission errors when measured. Multiple buckets are comma-separated.

For requests, q is 180 for sessions/free keys or 600 for builders, w is 60 seconds, r is remaining requests and t is seconds until the oldest request leaves that bucket. Audit w is 3600 seconds. Rolling windows release capacity gradually; these are observations, not reservations.

No individual bucket exists before identity is established. Those headers are omitted on anonymous discovery and may be absent after early ingress/authentication or database failures. Reads do not query hourly usage merely to fill headers. Shared IP state is disclosed only as "ip-requests" when that measured bucket causes a 429.

Follow Retry-After on 429/503 and progressive Markdown 202 responses. A successful read consumes a request, never an audit. Other limits can still reject a call even when one bucket has capacity.

Audit starts, everyone1,000 per rolling 5 minutes · 10,000 per hourGLOBAL_RATE_LIMITED
Audit starts, anonymous15 per hourPRINCIPAL_RATE_LIMITED
Audit starts, free30 per hourPRINCIPAL_RATE_LIMITED
Audit starts, builder150 per hourPRINCIPAL_RATE_LIMITED
Audit starts, per network, sessions30 per hourIP_RATE_LIMITED
Audit starts, per network, keyed150 per hour · logged only on builderIP_RATE_LIMITED
Fresh runs, per URL1 per 5 minutes for others · 1 per 60 seconds for the ownerTARGET_COOLDOWN
Requests, per principal180 per minute · 600 on builderREQUEST_RATE_LIMITED
Requests, per IP600 per minuteREQUEST_RATE_LIMITED
New sessions, per IP30 per hourIP_RATE_LIMITED
Live keys, per principal5KEY_LIMIT
Keys created, per network10 per dayKEY_ISSUANCE_LIMITED

Reusing a cached report spends a request, not an audit start. A key raises your own allowance and keeps a usage record; every plan shares the ceiling everyone shares.

Versioning

The API is in beta. Additive changes are recorded within /api/v1. Incompatible HTTP or report-format changes require an explicit new major contract and a documented migration. OpenAPI document, report-format and npm-package versions are independent. No endpoint is deprecated by this release, and no fixed overlap period is promised. A field marked experimental (today assistant_answers and ai_mode_answers) may change within v1; its versions key says which shape a report carries.

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.