Surfer REST API Documentation Audit
Surfer's v2 API ships genuinely agent-oriented reference material (llms.txt, per-resource markdown, detailed status semantics), but the machine-readable surface contradicts itself and the help center on version recommendations, idempotency conflict codes, rate limits, and even the 401 error envelope — exactly the kind of inconsistencies that make automated consumers fail silently.
1. Migration table marks v1-only tools as "Recommended API: v2" (critical)
Location: https://app.surferseo.com/llms.txt (v1/v2 Migration section)
Problem: Every row of the migration table says Recommended API: v2 — including tools that have no v2 endpoints. The Audit row reads "Audit | v2 | No v2 replacement yet.", the SERP Analyzer row says "v2" while its note instructs "Use plural endpoints (/api/v1/serp_analyzers, /api/v1/serp_analyzers/batch)", and AI Detector and Humanizer are marked "v2" with empty notes. The help center directly contradicts this: the API introduction says Audit, SERP Analyzer, AI Detector, and Humanizer are "currently supported only in API V1", and the examples article says "SERP Analyzer, Audit, AI Detector, and Humanizer remain on V1 — V2 equivalents are not yet available for those tools."
Consequence: This file exists specifically for machine consumption ("get an LLM-readable API index"). An agent parsing the table's Recommended API column will construct /api/v2/audits or /api/v2/ai_detector/... requests that don't exist, or refuse to use the working v1 endpoints because the index labels them non-recommended. Humans reading the notes can recover; a column-parsing agent cannot.
The fix: Make the Recommended API column reflect reality — set Audit, SERP Analyzer, AI Detector, and Humanizer to v1 (or v1 (no v2 yet)), matching the help-center articles.
2. AI Detector and Humanizer resource pages are empty stubs; the real docs are hidden under "Other" (critical)
Location: https://app.surferseo.com/llms/ai-detector.txt, https://app.surferseo.com/llms/humanizer.txt, https://app.surferseo.com/llms/other.txt
Problem: The llms.txt index lists AI Detector and Humanizer as first-class resources, but following those links yields 42- and 40-byte stubs: "## AI Detector — No documented operations." and "## Humanizer — No documented operations." The actual operations (POST /api/v1/ai_detector/detect, POST /api/v1/humanizer/humanize) are fully documented in /llms/other.txt under the unrelated heading "Other", alongside locations and deprecated workspace endpoints.
Consequence: A developer or agent that navigates the advertised resource pages concludes these features have no API at all — the stub literally says so — while the endpoints exist one file over. This is a navigation dead-end in the docs' primary machine-readable index.
The fix: Move the ai_detector and humanizer operations from other.txt into their named resource files, or delete the stub files and point the index entries at other.txt.
3. Idempotency-key conflict is 409 in two places and 422 in the endpoint reference (significant)
Location: https://app.surferseo.com/llms/content-editors.txt (POST /api/v2/workspaces/{workspace_id}/content_editors) vs https://app.surferseo.com/llms.txt (Errors) and https://docs.surferseo.com/en/articles/8196553-surfer-api-troubleshooting
Problem: The v2 create endpoint's Idempotency-Key parameter says "Reusing the same key for the same path returns the original response; reusing it for a different path returns 422." But the llms.txt error table documents "409 | conflict | Idempotency key conflict", and the help-center troubleshooting table says "409 | Conflict | Idempotency key conflict (V2 only) | Use a unique idempotency key per request." The create endpoint's own response list contains no 409 at all.
Consequence: Retry logic keyed on status codes will misclassify the failure: a client that treats 409 as "idempotency conflict, regenerate key" and 422 as "quota exceeded / validation error" (as the same docs teach elsewhere) will do the wrong thing on whichever code the API actually returns.
The fix: Pick one status code for idempotency conflicts, document it identically in the endpoint parameter description, the error table, and the troubleshooting article, and add it to the endpoint's response list.
4. Live 401 response doesn't match the documented error envelope (significant)
Location: https://app.surferseo.com/api/v2/workspaces (live behavior) vs every 401 example in https://app.surferseo.com/llms/*.txt
Problem: Every documented 401 example shows {"error": {"message": "Missing or invalid API key", "reason": "unauthorized", "details": []}}. An unauthenticated request to GET /api/v2/workspaces actually returns {"error":{"reason":"unauthorized","details":[]}} — no message field. The llms.txt Errors section presents the envelope with all three fields as the standard shape.
Consequence: Clients and SDK generators that treat error.message as a required string (as every example implies) will hit undefined/nil access on the very first failure mode a new integrator encounters — a missing API key.
The fix: Either return message on 401 responses or mark message as optional in the envelope documentation and examples.
5. Blanket rate-limit claim contradicts per-endpoint limits (significant)
Location: https://docs.surferseo.com/en/articles/8196553-surfer-api-troubleshooting (item 11) vs https://app.surferseo.com/llms/other.txt
Problem: The troubleshooting article states "All endpoints have a default limit of 10 requests/sec, except for SERP batch: 10 requests/min." But the AI Detector endpoint documents "Rate limit: 60 requests/min", and the Humanizer endpoint documents no rate limit at all. The two sources also disagree on what to check on a 429: the troubleshooting table says "Check x-ratelimit-reset", item 11 says check Retry-After.
Consequence: A developer sizing a batch AI-detection job off the "10 requests/sec" claim will run 10x over the endpoint's actual 60/min budget; Humanizer integrators have no documented budget at all.
The fix: Replace the blanket claim with a per-endpoint rate-limit table (or link to the per-operation limits), document the Humanizer limit, and standardize on which header to read after a 429.
6. 401 vs 403 for missing plan access — the intro and the error references disagree (significant)
Location: https://docs.surferseo.com/en/articles/5700335-surfer-api-introduction vs https://app.surferseo.com/llms.txt (Errors) and https://docs.surferseo.com/en/articles/8196553-surfer-api-troubleshooting (reference table)
Problem: The introduction says "Requests missing a valid key or made without the required plan or add-on will receive a 401 'Access Denied' response." But llms.txt documents the no-access case as "403 | permission_denied | Valid key but no access, including plan/billing", and the troubleshooting reference table agrees: "403 | Forbidden | Valid key, but this specific tool or endpoint isn't enabled on your account ... may require an add-on or higher plan tier." The same introduction then muddies it further: "You may receive errors such as 403 'Forbidden', 422 'Quota exceeded', or 500 'Internal server error', depending on the tool and the call."
Consequence: 401 means "fix your key" and 403 means "fix your plan" — a support-ticket-worthy distinction. Docs that map the missing-plan condition to 401 in one article and 403 (or even 500) elsewhere make it impossible to write correct error handling or self-diagnose access problems.
The fix: Document one canonical mapping (missing/invalid key → 401; valid key without plan/tool access → 403) and correct the introduction article to match the error tables.
7. The interactive docs page is an empty noindex SPA shell with no public OpenAPI spec (significant)
Location: https://app.surferseo.com/api/v2/docs
Problem: The canonical v2 docs URL returns an HTML shell titled "Loading... · Surfer" with <meta name="robots" content="noindex"> and zero documentation content in the markup. There is no published spec URL: /api/v2/docs/openapi.json returns the same HTML shell, and /api/v2/openapi.json returns 401 — the machine-readable spec exists but is auth-gated behind an API key that itself requires a paid plan and a support ticket. Deprecation notices in the llms files link into this SPA (e.g. /api/v2/docs#tag/Content-Editors/operation/KSeoWeb.Api.V2.ContentEditors.Controller.create), so non-browser consumers following "use this instead" links land on the loading shell.
Consequence: Search engines can't index the reference, agents fetching the docs URL get nothing, and nobody can generate a typed client without first buying a plan and emailing support for a key. The llms.txt files partially compensate, but nothing at the docs URL points to them.
The fix: Serve the OpenAPI spec unauthenticated (it documents a paid API; the spec isn't the product), add server-rendered content or at least a link to /llms.txt from the docs shell, and drop noindex.
8. v1 reference endpoints ship empty or stringly-typed responses (minor)
Location: https://app.surferseo.com/llms/audit.txt, https://app.surferseo.com/llms/serp-analyzer.txt
Problem: GET /api/v1/audits/{id} — the endpoint the docs tell you to poll ("Use this endpoint to poll for state updates") — documents its 200 response as "200 —" with no body, schema, or example. v1 error responses are bare strings like "422 — Quota exceeded | No access" with no envelope, while the help center separately documents that an empty Content Editor returns a 500 as an expected condition ("empty CE query that was never opened").
Consequence: You cannot write the polling loop the docs prescribe without guessing the response shape, and you must treat a 500 as a normal business state without any documented body to distinguish it from a real outage.
The fix: Add response schemas/examples to all v1 GET endpoints (at minimum the state enum for polling) and document the error envelope, including the intentional 500 case.
9. v2 examples contradict themselves within a single endpoint (minor)
Location: https://app.surferseo.com/llms/workspaces.txt, https://app.surferseo.com/llms/content-editors.txt
Problem: The GET /api/v2/workspaces 200 example shows meta.filters containing "inserted_after" and "state": "completed" — a filter key not among the documented parameters (filter[type], filter[state]) and a state value that doesn't fit the workspace states the prose describes (only active workspaces are usable); no valid values are enumerated for filter[type], filter[state], or sort. The Create Content Editor 201 example returns "state": "scheduled" yet already contains a computed content_score of 75, meta_title, and meta_description, and its custom_instructions ("Write from perspective of a senior developer") doesn't match the request example directly above ("Mention product X, write from perspective of Y").
Consequence: Developers infer field availability from examples: they'll expect scores and meta fields on a freshly scheduled editor (they're computed async, per the same page's webhook notes) and can't discover legal filter/sort values without trial and error against a 400.
The fix: Make examples internally consistent (scheduled state → null scores/meta, response mirrors request), and enumerate allowed values for filter, sort, and state parameters.
What they do well
- First-class llms.txt / llms-full.txt plus per-resource markdown files — a genuinely agent-consumable reference surface most competitors lack.
- v2 endpoint docs explain async semantics unusually well: status-value meanings, staleness (
calculatingreturns the prior score), webhook-vs-poll guidance, and non-backfilled-timestamp caveats. - Error catalog with stable
reasoncodes and a practical troubleshooting article mapping each status to a cause and fix.
Top 3 recommendations
- Fix the llms.txt migration table so "Recommended API" matches which endpoints actually exist, and fill the AI Detector/Humanizer stub files.
- Reconcile the cross-source contradictions (idempotency 409 vs 422, plan-gate 401 vs 403, rate limits) into one canonical error/limits reference, and make the live 401 envelope match it.
- Publish the OpenAPI spec unauthenticated and make the /api/v2/docs URL useful to non-browser consumers (link llms.txt, remove noindex).