Agent skill

MetaMonster API

The MetaMonster API exposes a customer's SEO data over HTTP. This one file is
self-contained — it has everything you need to authenticate and call every
endpoint correctly.

If your client supports MCP (Claude Code, Cursor, Windsurf, or claude.ai
web/Claude Desktop's connector UI), prefer the MCP server over
calling this API directly — it wraps the same endpoints in curated workflow
tools, so you get a verification loop instead of raw CRUD. Claude Code,
Cursor, and Windsurf use the same mm_ API key as this API; claude.ai and
Claude Desktop authenticate via OAuth instead — see the MCP doc for both
setup flows.

Base URL

https://new.metamonster.ai/api/v1

All paths below are relative to this. HTTPS only.

Authentication (do this first)

Every request except GET /health and GET /openapi.yaml needs an API key, sent as a Bearer token:

Authorization: Bearer mm_YOUR_API_KEY
  • Keys look like mm_ + 43 characters. They're created by a human in the dashboard (Settings → API Keys) — you cannot create, list, or revoke keys through the API. If you don't have a key, ask the user for one.
  • Verify a key with GET /me before doing real work. It returns { name, prefix, scopes }.
  • Never print, log, or commit the key. Read it from an environment variable (e.g. METAMONSTER_API_KEY) and pass it in the header.

Scopes — each key carries one or more:

Scope Grants
sites:read All reads: sites, pages, snapshots, content versions, analysis, drafts (GET), keyword metrics, job polling
content:write Writes: PATCH /pages (single + bulk PATCH /sites/{id}/pages), POST /pages/{id}/content, PUT/DELETE /pages/{id}/drafts/{field}, POST /pages/{id}/analyze (+ bulk POST /sites/{id}/analyze), POST /pages/{id}/recrawl, and the recommendation verbs (dismiss/resolve/recheck)
cms:publish Reserved — no public endpoint uses it yet

Dashboard-minted keys carry all three. A missing scope → 403 forbidden (Missing required scope: <scope>).

Auth errors (all HTTP 401): Missing API key (no header), Invalid API key (no match), API key revoked, API key expired.

Response conventions

  • Detail endpoints return { "data": { … } }. List endpoints return { "data": [ … ], "meta": { "total", "page", "per_page" } }.
  • Exceptions: GET /health{ "ok": true }; GET /me{ "name", "prefix", "scopes" } (no data); GET /jobs?ids= is a list but a lookup by id (meta: {requested, found}), and GET /pages/{id}/links is a capped dump (meta: {total, truncated}, total = returned rows) — neither takes page/limit. Complete-but-unpaginated arrays: GET /pages/{id}/drafts, the checks array in GET /pages/{id}/checks, and components/recommendations inside GET /pages/{id}/analysis.
  • Errors return { "error": { "code", "message", "details?" } } with an HTTP status — on every endpoint, /me and /health included. details is an optional object with machine-readable context for specific codes (e.g. payment_required carries { limit, used, remaining }). Switch on code: invalid_request (400), no_content (400, nothing to act on — e.g. triggering analysis/recrawl with no content or URL), no_content_to_validate (400, a recommendation verb couldn't resolve content to validate), unauthorized (401), payment_required (402, plan allowance exhausted — see the analyze trigger's three entitlement paths), forbidden (403), not_found (404), conflict (409), keyword_generation_in_progress (409, keyword generation running for the site — retry after it completes), analysis_in_progress (409, poll the existing job instead of retriggering), crawl_in_progress (409, poll the existing job instead of retriggering), path_conflict (409, recrawl new_path collides with another page), already_resolved (409, recommendation dismiss/resolve on an already-dismissed/applied rec), not_applied (409, recheck on a rec that isn't currently applied), rate_limited (429), generation_cap_reached (429, per-analysis generation cap — distinct from rate limiting), engine_paused (503, the preview checks engine is paused for maintenance — nothing was queued, retry later), internal_error (5xx).
  • A resource you don't own returns 404, not 403 — don't interpret 404 as "definitely doesn't exist"; it may mean "not in this org."
  • Pagination: page (1-indexed) + limit (max 100; defaults: sites 15, pages 50, snapshots 20). Loop until page * per_page >= meta.total.
  • Rate limit: per key, 60s fixed window, three independent buckets — ~120 req/60s default, a separate ~600 req/60s bucket for job polling (GET /jobs/{jobId} and GET /jobs?ids=) so polling never eats your general budget, and a small ~6 req/60s bucket for POST /pages/{pageId}/checks/recommendations (a model call per request, no allowance debit). Response headers X-RateLimit-Limit/-Remaining/-Reset describe whichever bucket the request spent. On 429, honor the Retry-After header. Failed-auth requests are throttled separately per IP — if you see 429 Too many unauthenticated requests, fix the key rather than retrying.
  • Timestamps are ISO-8601 UTC; empty values are null; IDs are integers.

The golden path

Data is hierarchical: sites → pages → { content, snapshots, analysis, drafts }. To act on a page you almost always start from its site.

  1. GET /sites — find the site (has id, domain, page_count). Already have a URL instead of a site? GET /pages/lookup?url= resolves it straight to a page, skipping steps 1-2 (404 tells you whether to POST /sites or POST /sites/{siteId}/pages if it doesn't exist yet).
  2. GET /sites/{siteId}/pages — find pages. Rich filters: search, is_priority, has_keyword, has_draft, has_recommendations, grade, min_score/max_score, sort, direction.
  3. Or skip browsing and let the site tell you what to work on: GET /sites/{siteId}/opportunities → pick a page_id from opportunities[] (or the flat cross-page recommendations[]) → GET /pages/{pageId}/brief for the full work packet in one call → make your edits → close the loop with the recommendation verbs (resolve/recheck — see The verification loop). This is the preferred path for an agent picking its own work; the two-step site→pages browse above is for when the user names a specific page. Note: opportunities[] excludes the homepage (it's surfaced separately at homepage) and only lists graded, keyworded pages — check graded/recommendation_count before assuming a row is actionable, and don't confuse a row's score (0–100 opportunity/demand ranking) with the page's actual content grade (analysis.overall_score/overall_grade from the brief or /analysis).
  4. GET /pages/{pageId} — full page detail (embeds latest snapshot + analysis summary + active drafts).
  5. Then, as needed: GET /pages/{pageId}/content (body as markdown), /analysis (scores incl. estimated_score/estimated_grade + recommendations, each with a content_excerpt), /snapshots (history), /content-versions (version history), /drafts (field drafts), /brief (bundles step 4's page summary plus most of this step — fields incl. schema with validation, analysis with estimated_score, recommendations, keywords, queries, outline, links — into one call; fields.body.content_version_id doubles as base_version_id for POST /pages/{pageId}/content, so you don't need a separate GET /content just to get it), /report (the brief plus comparison vs the previous analysis and a serp section — what to read after an analyze completes), /links (outbound links), /outline (latest content outline), /queries//performance (live Search Console data). A recommendation's content_excerpt is null for add-type recs (no target nodes) or after a crawl/recrawl regenerated the document's node ids since the analysis ran — POST /pages/{pageId}/analyze to refresh targeting.

Link signal: the "add internal links" recommendation is scored against the content GET /pages/{pageId}/content currently returns, not the crawler's view of the live site — links added via POST /pages/{pageId}/content count on the next analyze; links that only exist in the crawled nav/footer (GET /pages/{pageId}/links) don't count toward it.

Making changes (needs content:write)

  • Creating a page: POST /sites/{siteId}/pages with { path, crawl?: boolean }. Add crawl: true to scrape it immediately (responds 202 + job instead of 201) — the fast lane for a URL MetaMonster hasn't seen yet, e.g. after GET /pages/lookup 404s with a site_id. To add a whole new site first, POST /sites with { domain }.
  • Page metadata (keywords, priority, page context, Search Console URL): PATCH /pages/{pageId} with { primary_keyword?, secondary_keywords?, is_priority?, purpose?, gsc_url? }. Send at least one field; unknown fields are rejected. A primary_keyword/secondary_keywords write can 409 (keyword_generation_in_progress) if a keyword-gen workflow is running for the site — retry after it finishes.
  • Same patch across many pages: PATCH /sites/{siteId}/pages with { page_ids: [...], set: {...} } — up to 50 pages of one site per call, same fields minus gsc_url. Don't loop the single PATCH for this. It's partially successful: unknown/foreign ids come back in failed (code: not_found) instead of failing the call, so read meta.updated/meta.failed rather than the status code.
  • Body content: POST /pages/{pageId}/content with exactly one of markdown or doc. Pass base_version_id (from GET …/content) to guard against overwriting concurrent edits — a mismatch returns 409. Response: 201 new version, or 200 if collapsed into the current one. To roll back to a prior version, GET /pages/{pageId}/content-versions/{versionId} for its body_markdown and re-POST it.
  • Field drafts: PUT /pages/{pageId}/drafts/{field} with { draft_value } (≤20,000 chars). fieldtitle, meta_description, h1, primary_keyword, body, image_alt, schema. DELETE the same path to discard. These stage changes — publishing to a CMS is a separate product action, not exposed here. For field=schema, draft_value must be parseable JSON (else 400 invalid_request) — schema.org rule violations are advisory and don't block the save, but come back as a validation object alongside data so you can see what to fix. Property checks cover common @types (Organization, LocalBusiness, Article, Product, Event, JobPosting, Course, SoftwareApplication, Service, etc.) and their well-known schema.org subtypes, which inherit the parent type's required/recommended properties (e.g. MovingCompanyLocalBusiness, TechArticleArticle). A blank draft_value clears the field and skips validation.

None of these write endpoints run AI generation — they store exactly what you send.

Triggering work (needs content:write; async — 202 + job)

These endpoints kick off work that runs in a background worker instead of finishing inline. They respond 202 with a job_id (one per page) and a retry_after_seconds poll hint; poll GET /jobs/{jobId} (below) until status leaves queued/processing, sleeping retry_after_seconds between polls — or GET /jobs?ids= for up to 50 jobs in one request when you triggered a batch. Honour retry_after_seconds (also sent as a Retry-After header on non-terminal job responses) instead of hardcoding an interval or hot-looping — it's server-controlled, and job polling has its own rate-limit bucket sized for exactly that cadence.

  • POST /pages/{pageId}/analyze — body { force?: boolean }. Requires an active subscription, except a page's homepage gets one free preview audit. Without force, an unchanged content fingerprint completes as job status skipped — no new analysis. 409 analysis_in_progress if one's already running for the page. After the job completes, GET /pages/{pageId}/report is the one-call read of the result.
  • POST /sites/{siteId}/analyze — the bulk form: body { page_ids?: int[] (1-50) | filter?: "priority", force?: boolean } (exactly one selector). Don't loop the single trigger for a batch. Always 202, even when nothing queued — each page either lands in data.jobs[] (poll each job_id as usual) or in data.skipped[] with the reason the single route would have raised (not_found, no_content, analysis_in_progress, allowance_exhausted, payment_required, error). Branch on meta.queued, not the status code. Poll the whole batch with GET /jobs?ids= (one request), don't loop GET /jobs/{jobId}. Allowance is re-checked per page, so an oversized batch partial-queues rather than failing.
  • POST /pages/{pageId}/recrawl — body { new_path?: string }. Re-fetches the live page (use after edits made directly on the site, not through this API). Requires an active subscription (no free-preview carve-out). 409 crawl_in_progress if any crawl is already running for the site. Advances the page's content version (source: "crawl") and marks the current analysis content_stale: true — any held base_version_id will 409 afterward, so re-GET /pages/{pageId}/content after a recrawl completes. Pass new_path when the slug changed on the live site — always use new_path for a rename; a slug rename picked up by a routine full-site crawl (not through this endpoint) creates a brand-new page record instead of updating this one, orphaning the old page's history.

Recommendation verbs (needs content:write)

Act on individual recommendations from an analysis by recId. All three return { recommendation, analysis } — the updated rec plus a refreshed score summary, in one response.

  • POST /recommendations/{recId}/dismiss — body { rationale?: string }. "Not doing this." 409 already_resolved if already dismissed/applied.
  • POST /recommendations/{recId}/resolve — body { content?: string, force?: boolean }. "I made this change." AI-validates (Haiku) the current content against the recommendation; on a pass, marks it applied and scores recalculate. All recs auto-resolve content if you omit it: body_content recs use the targeted doc nodes plus the full document as context (or the full doc alone); title/meta_description/h1/schema recs default to the field's active draft, falling back to the latest crawled snapshot value. So the usual pattern for a metadata/schema rec is PUT /pages/{id}/drafts/{field} (write the fix) → resolve with no body — no need to pass content unless you want a one-shot check against something you haven't saved. On unavailability the validator auto-passes with skipped: true (availability-first — never blocks on a telemetry check); a skipped verdict does NOT count toward the verified scorerecheck to confirm it. Response adds { addressed, reasoning, skipped, placeholders, validation_scope, validated_node_ids }. 400 no_content_to_validate if nothing could be resolved (no draft and no snapshot value either) — the rec stays open and nothing is written; 409 already_resolved if already dismissed/applied.
  • POST /recommendations/{recId}/recheck — no body. Re-verify an already-applied rec after further edits (typically clearing placeholders, or confirming a skipped: true verdict). The only gate is status: applied, so a skipped rec is always re-checkable and a real verdict clears the flag; the rec stays applied either way — a failing recheck rewrites validation_result only, and un-promotes the verified score. Response adds the same { addressed, reasoning, skipped, placeholders, validation_scope, validated_node_ids } block as resolve. 409 not_applied if the rec isn't currently applied. Recheck reads content from MetaMonster's own state — the latest content version for body recs, the field's current draft (falling back to the snapshot) for metadata/schema recs — so resolve with inline content (which stores nothing) leaves a metadata/schema rec with nothing to recheck: PUT /pages/{id}/drafts/{field} first, or you'll get 400 no_content_to_validate.

See The verification loop below for how these fit together with analyze/recrawl.

Iterate cheaply (spend analysis runs last)

An analysis run costs the customer's plan allowance and takes ~20s of model time. Two endpoints let you do the obvious work for free first, so a run is only ever spent on judgement:

  1. GET /sites/{siteId}/rubric — what this site's grader actually measures: components, weights, and every criterion's real instruction (criteria[].description). Read it once per site and keep it for the session; it's what the score is made of, so it tells you what to write, and its criteria[].keys join to criteria_scores[].key and a rec's rubric_criteria. Free, instant, no allowance.
  2. GET /pages/{pageId}/checks — the page's mechanical state right now: title/meta lengths, keyword placement, H1 count, word count, JSON-LD validity, link counts. Free, instant, no allowance, no LLM. Fix everything in summary.fail before spending a run — the grader docks the same misses, and paying a model to report them is waste.
  3. Edit: PATCH /pages/{pageId} (keywords, priority, purpose) · PUT /pages/{pageId}/drafts/{field} (title/meta/h1/schema) · POST /pages/{pageId}/content (body). All free.
  4. GET /pages/{pageId}/checks again — confirm the check you aimed at flipped, and that content_changed_since_last_analysis is true. If it's false, your edit never reached the fingerprint and an analyze without force will come back skipped — fix that before spending the run rather than forcing past it.
  5. POST /pages/{pageId}/analyze — now spend it. Several pages? POST /sites/{siteId}/analyze (≤50, one call).
  6. Poll GET /jobs/{jobId} (or GET /jobs?ids= for the batch) until status leaves queued/processing, sleeping the response's retry_after_seconds — server-controlled, never hardcode it, never hot-loop. retry_after_seconds: null is your stop signal.
  7. Read the movement, not just the number: GET /pages/{pageId}/analysis?compare=prev returns the new analysis plus a comparison block (score/component/criterion deltas, and which recs are resolved/still_open/new/dropped). GET /pages/{pageId}/analyses?limit=2 gives you the raw pair instead, and a longer limit the score history.

Checks are hygiene, not the grade. They settle what a machine can settle; overall_score/overall_grade come from the LLM grading the page against the rubric. A page can pass all 15 checks and still grade a C — checks can't judge whether the content is useful, differentiated, or better than what's already ranking. Never report check results as a score, and never claim a grade moved without a completed analysis.

Two gotchas: checks link counts come from the latest crawl, so links you just wrote into a draft or content version don't appear there until a recrawl (the analysis internal-link signal does see them — it reads the content). And content_changed_since_last_analysis can read true on a page whose body only exists as crawled snapshot markdown, until its first analysis run writes a content version.

The verification loop

This is the core agentic workflow: read what's wrong, fix it, tell the API you fixed it, and confirm the score moved. It picks up where Iterate cheaply leaves off — that section is the free pass over a page's mechanics; this one is closing out the graded recommendations. Full recipe:

  1. GET /pages/{pageId}/analysis — read recommendations. Each carries title, description, target_field, and (for body_content recs) content_excerpt — the exact passage to change, in markdown, no ProseMirror ids needed.
  2. Make the edit. Either:
    • POST /pages/{pageId}/content (markdown/doc) or PUT /pages/{pageId}/drafts/{field} — for edits made through this API, or
    • Edit directly on the live site (CMS, code, whatever) — for edits made outside this API. You'll need step 6 (recrawl) to bring MetaMonster up to date afterward.
  3. POST /recommendations/{recId}/resolve for each recommendation you addressed. For title/meta_description/h1/schema recs, if you fixed the field via PUT /pages/{pageId}/drafts/{field} in step 2, call resolve with no body — it validates against that draft automatically (for schema, write valid JSON-LD first; see recommendations.md#the-schema-workflow). Read addressed/reasoning off the response:
    • addressed: true, skipped: false, placeholders: [] → the recommendation is now applied and its points count toward the verified score. Done.
    • addressed: true with non-empty placeholders → the structural change landed but template text ("[Your Company]", TODO, etc.) still needs replacing before this counts toward a promoted score.
    • addressed: true with skipped: true → nothing actually checked it (you passed force: true, or the validator was unavailable). It's applied and counts toward estimated_score, but not toward the verified score until a real recheck confirms it.
    • addressed: false → the recommendation is still open; reasoning explains why the validator didn't buy it. Adjust the edit and resolve again.
    • validation_scope tells you what the verdict was formed against (inline_content / targeted_nodes / full_document / field_draft / field_snapshot, or null for force) — useful when addressed: false surprises you. Body recs always get the full document as context on top of validated_node_ids, so a change made in a neighbouring node still counts.
  4. If placeholders was non-empty, or skipped was true: fix the placeholder text (nothing to fix for skipped), then POST /recommendations/{recId}/recheck. A clean recheck (placeholders: [], skipped: false, addressed: true) is what promotes overall_score/score_source from audit to verified.
  5. Once you've resolved everything you're going to for this pass, re-score:
    • If all edits went through POST /content/PUT /drafts (this API): POST /pages/{pageId}/analyze { "force": true } — force is usually right here since you just changed content and want a fresh number, not a skipped job.
    • If any edits were made directly on the live site: POST /pages/{pageId}/recrawl first (with new_path if a slug changed), poll it to completed, then POST /pages/{pageId}/analyze { "force": true }.
  6. Poll GET /jobs/{jobId} until status leaves queued/processing, waiting retry_after_seconds between polls. Analyzing several pages? Trigger with POST /sites/{siteId}/analyze and poll them together with GET /jobs?ids=.
  7. Read the new score — either off the completed job's result (analysis_id, overall_score, overall_grade, score_source), or GET /pages/{pageId}/analysis?compare=prev for the full picture plus a comparison block: score/component/criterion deltas and which recommendations are resolved, still_open, new or dropped since the previous run. That's the movement in one call — use GET /pages/{pageId}/analyses?limit=2 when you want the raw pair of runs instead.

Back off per error code, don't just retry:

  • analysis_in_progress / crawl_in_progress → don't retrigger. Find the in-flight job (you likely already have its id from the earlier trigger call) and poll that instead.
  • payment_required → stop. This is a billing wall, not a transient failure — surface it to the user rather than retrying or working around it.
  • engine_paused (503, checks engine only) → nothing was queued and nothing else is affected. Wait and retry later; don't loop, and don't fall back to the legacy analyze trigger expecting checks output.
  • A job that completes skipped → the content fingerprint hadn't changed, so nothing new was scored. Check content_changed_since_last_analysis on GET /pages/{pageId}/checks before re-triggering: false means your edit never landed in the fingerprint. If you genuinely need a fresh audit (e.g. you're not confident the fingerprint reflects your edit), re-run with force: true. Don't force by default — it burns a real analysis run.

Discovery (S: sites:read)

Endpoints for finding what to work on and gathering everything needed to work on it, without browsing every page individually.

  • GET /sites/{siteId}/opportunities — ranked "what should I work on" report: opportunities[] (scored pages, with per-row recommendations[]), a flat cross-page recommendations[] (up to 8 highlights), and GSC/keyword-opportunity summaries. Read-only — never triggers a recompute, and there's no trigger endpoint. Requires an active subscription: 402 payment_required if the org isn't subscribed. Cached ~60s per site (X-Cache: HIT|MISS). If view_state != "ready", the pipeline is still running — poll on view_state, not sealed (sealed: true only means generation.total stopped growing, not that the pipeline finished). gsc.avg_position doesn't exist; use summary.scopes.top.now.avg_position / summary.scopes.all.now.avg_position. The homepage never appears in opportunities[] — it's its own row at homepage. opportunities[] also excludes any page that isn't graded and keyworded yet.
  • GET /sites/{siteId}/rubric — the criteria the grader scores against for this site, with per-component weights and the score→grade bands. Free and instant; read it before spending a run. Its criteria[].keys join back to criteria_scores and a recommendation's rubric_criteria. See Iterate cheaply.
  • GET /pages/{pageId}/checks — deterministic hygiene checks on the page's current state (title/meta lengths, keyword placement, H1 count, word count, JSON-LD validity, link counts). Free, instant, no analysis allowance, no LLM — and not the grade. content_changed_since_last_analysis tells you whether a re-analysis would see anything new. See Iterate cheaply.
  • GET /pages/{pageId}/brief — the page work packet: summary, resolved field values (title/meta/h1/body markdown, body also carrying content_version_id for use as base_version_id on POST /pages/{pageId}/content), latest analysis + components + estimated_score/estimated_grade, open recommendations with content_excerpt, keywords with cached metrics, 28-day GSC top queries, an outline summary, and links — one call instead of six. Sections degrade independently (e.g. analysis: null, queries.gsc_status: "unavailable"); only the page itself 404s.
  • GET /pages/{pageId}/links — outbound links from the page's latest crawl, ordered by position. Hard cap 1000 rows — check meta.truncated, not meta.total (which is just the returned-row count).
  • GET /pages/{pageId}/outline — the page's latest content outline (any status) with its sections and the SERP snapshot (competitor heading trees — never competitor body text) it was generated from. 404 means the page has never had an outline generated yet, not an error — this endpoint is read-only in this release; triggering generation over the API isn't available yet.

Search Console data (S: sites:read)

Three endpoints read live Google Search Console data — no fact tables back them, so every call is a real request to Google (budget ~1s latency and your per-key rate limit; don't poll these tightly).

  • GET /sites/{siteId}/performance — live per-URL performance for the whole site, canonical host only, with page_id set when a URL matches a tracked page (#fragment/?query variants resolve to the same page). Add group_by=page to fold those variants into one row per page (clicks/impressions summed, ctr recomputed, position impressions-weighted, url the page's canonical URL) — folding runs before the cap. Rows are capped at 1000 (top by clicks) — check meta.truncated; totals still covers the whole site. Cache-Control: private, max-age=300.
  • GET /pages/{pageId}/queries — top queries (by clicks, ≤50) for one page.
  • GET /pages/{pageId}/performance — daily series for one page (days with no data are absent, not zero-filled) plus window totals.
  • Per-page GSC endpoints measure one exact URL (the page's gsc_url, falling back to url). If a page's numbers look far below what GET /sites/{siteId}/performance attributes to its page_id, the stored gsc_url is pointing at a variant — repoint or clear it with PATCH /pages/{pageId} {"gsc_url": ...|null}.

All three take the same window: days (1–365, default 28, ends 2 days back for GSC's reporting lag) or start_date+end_date (paired, ≤365-day span) — explicit dates win when given.

gsc_status rules — check this before trusting data: available → use data normally. not_indexed → nothing to fetch, don't retry. not_connected → tell the user to connect Search Console for the site in the MetaMonster dashboard; don't retry. unavailable → the live Google call failed — back off and retry later, don't retry-loop. Google failures never 5xx; they resolve to gsc_status: "unavailable" with empty data.

Prefer the brief's bundled queries. GET /pages/{pageId}/brief already includes a 28-day queries section — if you're calling the brief anyway (or about to), read GSC from there instead of a separate live call; reserve /queries and /performance for when you need a custom window.

Rules of engagement

  • Confirm before writing. PATCH, POST /content, PUT/DELETE /drafts, the recommendation verbs, and triggering analyze/recrawl all mutate customer data or spend allowance. Unless the user clearly asked for the change, confirm first, and echo back what you're about to change.
  • Read before you write. Fetch current state (GET /pages/{id} or …/content) so you're modifying from a known baseline, and can pass base_version_id.
  • Handle 404 as "missing or not yours." Don't retry blindly.
  • Respect rate limits. Batch reads, paginate deliberately, back off on 429.
  • Keyword metrics are read-only cache lookups. GET /keywords/metrics?keyword=… returns 404 on a cache miss — it never fetches fresh data.

Endpoint reference

Legend: S = required scope. Base + auth + envelope + pagination as above.

Health & identity

GET /health · S: none

No auth (per-IP rate limited). → 200 {ok:true}.

GET /openapi.yaml · S: none

No auth (per-IP rate limited). → 200, the full OpenAPI 3.1 spec as YAML.

GET /me · S: any key

200 {name, prefix, scopes[]} (no data wrapper). 401 unauthorized (enveloped) on bad key.

Sites (S: sites:read)

GET /sites

Query: page, limit (default 15).
{data: SiteSummary[], meta}.
SiteSummary: {id, domain, url|null, name|null, created_at|null, last_crawled_at|null, page_count, draft_count}.

POST /sites · S: content:write

Body: {domain: string, auto_run_keyword_scope?: none|homepage|top|all (default none), auto_run_analysis_scope?: none|homepage|top|all (default none)}. Adds a site and queues the standard setup job (page discovery → GSC enrichment → crawl) — the API twin of the dashboard's add-site form. Auto-run scopes default to none so this never spends keyword/analysis allowance on its own; opt in explicitly. Unsubscribed orgs are capped at one free site-run and have their scopes clamped down (all keywords → top; top/all analysis → homepage).
201 {data: SiteDetail}. Errors: 400 (validation), 402 payment_required.

GET /sites/{siteId}

{data: SiteDetail}. 404 Site not found.
SiteDetail: {id, domain, url|null, name|null, business_context|null, voice_tone|null, gsc_connected|null, gsc_property_url|null, background_status|null, score_curve|null, location:{country_code|null,country_name|null,city_code|null,city_name|null}, llms_txt|null, setup_completed_at|null, discovery_completed_at|null, created_at|null, updated_at|null, last_crawled_at|null, latest_crawl:{id,status,total_pages|null,crawled_pages|null,started_at|null,completed_at|null}|null, page_count, draft_count}. url is the homepage URL derived from domain (https:// + the lowercased domain, no trailing slash) — null when domain is not a valid bare hostname (planned/pre-launch site holding a display name, missing domain, or a domain that includes a path). background_status is non-null while setup/crawl/keyword-gen runs in the background (discovering, enriching, crawling, generating_context); page lists and opportunity scores may be incomplete until it returns to null.

GET /sites/{siteId}/performance

Query: days(1-365, default 28) or start_date+end_date(YYYY-MM-DD, paired, ≤365-day span), group_by=page (optional; only page is valid). Live from Google, canonical host only. page_id matches by canonical page key, so Google's #fragment/?query variants of a URL resolve to the same page. group_by=page folds them into one row per page — clicks/impressions summed, ctr = clicks/impressions (0 with no impressions), position the impressions-weighted mean, url the page's canonical URL; unmatched rows fold by their canonical key with page_id: null. Folding happens BEFORE the 1000-row cap. → {data: SitePerformanceRow[], totals: GscMetrics|null, gsc_status, window, meta: {total, truncated}}. Rows sorted by clicks desc (impressions tiebreak) and hard-capped at 1000meta.total is what came back, meta.truncated says whether the tail was cut; totals covers the whole site either way. Cache-Control: private, max-age=300. 400 on bad window params.
SitePerformanceRow: {url, page_id|null, clicks, impressions, ctr, position}. gsc_statusavailable|not_connected|unavailable (site-level never returns not_indexed) — see Search Console data above.

GET /sites/{siteId}/opportunities

{data: Opportunities}. Read-only ranked "what to work on" report — never triggers a recompute. 402 payment_required if the org isn't on an active subscription. Cached ~60s per site (X-Cache: HIT|MISS). See Discovery above for view_state/generation polling and the gsc.avg_position gotcha (it's at summary.scopes.*.now.avg_position instead).

GET /sites/{siteId}/rubric

{data: Rubric}. What the LLM grader scores this site's pages against — read it before spending an analysis run.
Rubric: {components: RubricComponent[], grade_bands: [{grade, min}], score_curve|null}.
RubricComponent: {component_type, label, weight, is_system, criteria: [{key, label, description, category|null, max_score}]}. System components come first in list order, then any custom ones the site owner added (order is presentation only — components are graded in parallel); criteria may be empty for a custom component.
component_type matches component_type on an analysis Component, and criteria[].key matches criteria_scores[].key there and rubric_criteria on a Recommendation — join on them to see the full instruction behind a low line item. weight (0–1, system default 1) is the component's share of the weighted-average overall score — one at 1.0 counts twice as much as one at 0.5. grade_bands is highest-first: a score takes the first band whose min it reaches (85 → B), and F/min: 0 is the floor. score_curve (none|medium|large) is applied before grading, so a raw criteria total isn't directly comparable to a page's overall_score.
Prompts, models, tools and context fields are internal and never returned. The rubric is per site — don't reuse one site's response for another.

GET /sites/{siteId}/facts

Query: kind (optional, one of identity|contact|listing|testimonial|stat|award|client|person), status (optional, active|stale|all, default active), search (optional, 1-200 chars, case-insensitive match across quotes/names/labels/values), include (optional, citations to attach each fact's citations), limit (optional, 1-200, default 100), offset (optional, default 0). → {data: SiteFact[], meta: {total, sheet_count}}. meta.total is the filtered count before pagination; meta.sheet_count is how many of them are in_sheet.
SiteFact: {id, kind, key|null, data, source, source_url|null, verified, status, citation_count, last_verified_at|null, in_sheet, created_at, updated_at, citations?}. sourcemanual|api (a person or POST set it) or crawl|schema_org|google_business (extracted — never attached unless verified from the site itself or a domain-matched Google Business listing). statusactive|stale — a stale fact was found on the site before and isn't any more; never use one. citation_count is how many active citations back this fact (0 = predates citations or hand-entered, no receipt to check). in_sheet marks the ranked, budgeted subset the content generator actually sees — the rest are readable here but not prompted with; use search to find them. citations (a SiteFactCitation[]) is present only when include=citations was passed. data's shape depends on kind: identity/contact are {value} keyed by key (identity: company_name|legal_name|tagline|founding_year|industry|service_area|team_size; contact: phone|email|address|hours|booking_url|social:<platform>); listing (key always google, singleton) is {rating, review_count, url, place_id?, address?, phone?}; testimonial is {quote, author_name, author_title?, author_company?, rating?, date?, platform}; stat is {label, value, context?, as_of?}; award is {name, issuer?, year?}; client is {name, type, url?}; person is {name, role, bio?, credentials?, url?} — these five have key: null.
SiteFactCitation: {id, page_id|null, source, source_url, evidence, locator|null, status, first_seen_at, last_verified_at}. page_id is null for a Google Business listing (no page). statusactive|gonegone means a later crawl no longer found this evidence. evidence is the exact text the fact was extracted from — check it before writing the fact's value into content.
Read facts before writing content that states a number, a name, or a quote — reuse what's here instead of inventing one. Fetch with include=citations and check the evidence before trusting a specific claim.

POST /sites/{siteId}/facts · S: content:write

Body: {facts: [{kind, key?, data}, ...]} (1–100). Records facts the USER stated in the conversation — never invent one. source is always forced to api. → 201 {data: {inserted: SiteFact[], updated: SiteFact[], skipped: [{index, reason}]}}index is the position in the facts array you sent. reasoninvalid|edited|dismissed|lower_precedence|unchanged|duplicate|low_rating; edited means a person already edited an equivalent fact in the dashboard — PATCH that fact instead of resubmitting; low_rating means a testimonial's data.rating was set and below 4 stars, which is never stored.

PATCH /sites/{siteId}/facts/{factId} · S: content:write

Body: {key?, data} (data required, validated against the fact's existing kind). → {data: SiteFact}. Marks the fact user-edited so extraction leaves it alone.

DELETE /sites/{siteId}/facts/{factId} · S: content:write

{data: {mode: "deleted"|"dismissed"}}. manual/api facts are hard-deleted; extracted facts are dismissed (kept, hidden from GET) so a rescan can't resurrect them. 404 for a bad factId or one from another org's site.

Pages

GET /pages/lookup · S: sites:read

Query: url (required, absolute URL, ≤2048 chars). Resolves an absolute URL to one of your pages — host matching ignores www., path matching ignores trailing slashes, on multiple matches the newest site wins, then its most recently updated live page. → {data: PageLookup}. 404 page_not_found (details.site_id set — create the page with POST /sites/{siteId}/pages) or 404 site_not_found (details.domain set — add it with POST /sites).

GET /sites/{siteId}/pages · S: sites:read

Query: search(≤200), is_priority(true|false), has_keyword(true|false), has_draft(true|false), has_recommendations(true|false), grade(string), min_score(num), max_score(num), sort(created_at|updated_at|url|path|is_priority|primary_keyword|opportunity_score; default path), direction(asc|desc; default asc), page, limit(default 50).
Priority pages always sort first. → {data: PageSummary[], meta}. 404 Site not found.
PageSummary: {id, url, path|null, site_id|null, title|null, primary_keyword|null, is_priority, opportunity_score|null} (title from latest snapshot).

POST /sites/{siteId}/pages · S: content:write

Body: {path: string(1-500), crawl?: boolean (default false), title?, purpose?, primary_keyword?, secondary_keywords?}. Creates a page by path on the site (path normalized to lowercase/leading-slash). With crawl: true it's immediately scraped via the single-page recrawl pipeline and responds 202 + job (poll GET /jobs/{job_id}) instead of 201 — the fast lane for "analyze this URL MetaMonster hasn't seen yet". The crawl: true path requires an active subscription but deliberately skips the site-level crawl_in_progress guard POST /pages/{pageId}/recrawl enforces, so it still runs even mid-setup-crawl.
201 {data: PageSummary} or 202 {data: {job_id, status:"processing", page_id, retry_after_seconds}}. Errors: 400 (validation), 402 payment_required (crawl path only), 404 Site not found, 409 (path already exists on the site).

GET /pages/{pageId} · S: sites:read

{data: PageDetail}. 404 Page not found.
PageDetail: {id, url, path|null, name|null, purpose|null, planned, site_id|null, primary_keyword|null, secondary_keywords[]|null, is_priority, is_priority_manual|null, opportunity_score|null, gsc_url|null, gsc_indexed|null, discovered_via|null, created_at|null, updated_at|null, last_analyzed_at|null, latest_snapshot: SnapshotLean|null, latest_analysis:{id,overall_score|null,overall_grade|null,status,completed_at|null,score_source,content_stale}|null, drafts:[{field,status,draft_value|null}]}. name is read-only (planned-page workflows). purpose is page context that feeds keyword/outline prompts — writable via PATCH. planned: true = page was planned before the site went live (no crawled snapshot yet). score_source on the embed is audit or verified; content_stale: true means content changed since this analysis ran — treat the score as void until a re-analysis completes.

PATCH /pages/{pageId} · S: content:write

Body (≥1 field, unknown keys rejected): {primary_keyword?: string(1-200)|null, secondary_keywords?: string[](≤100, each 1-200)|null, is_priority?: boolean, purpose?: string(≤2000)|null, gsc_url?: string(≤2048)|null}. Setting is_priority also sets is_priority_manual. purpose is trimmed; empty/whitespace-only clears it to null. gsc_url is the exact URL per-page GSC reads filter on — self-heal a page whose Search Console numbers look wrong by repointing it (absolute http(s) URL on the site's canonical host, no #fragment400 otherwise) or sending null to clear it so the next reconciliation re-derives it; gsc_indexed is left untouched.
200 {data: PageDetail} — the same shape as GET /pages/{pageId}, reflecting the post-update state.
Errors: 400 (No fields to update / validation / unknown key), 404 Page not found, 409 keyword_generation_in_progress (a primary_keyword/secondary_keywords write landed while a keyword-generation workflow is running for the site — retry after it finishes; is_priority/purpose alone are never gated).

PATCH /sites/{siteId}/pages · S: content:write

Bulk form of the above — one patch, up to 50 pages of the site. Body: {page_ids: int[] (1-50, unique, each ≥1), set: {primary_keyword?, secondary_keywords?, is_priority?, purpose?}} (≥1 key in set; same validation as the single PATCH; gsc_url is not accepted here — it's per-page).
200 {data: {updated: PageDetail[], failed: [{page_id, code: 'not_found'|'error', message}]}, meta: {requested, updated, failed}}. Both arrays are in request order and requested === updated + failed — an all-failed call is still 200, so check meta. not_found = not a live page of this site (wrong site, other org, deleted, nonexistent — indistinguishable); error messages are generic (Failed to update page) — the cause is logged server-side.
Errors: 400 (empty/>50/duplicate/non-positive page_ids; empty set; unknown key incl. gsc_url), 404 Site not found, 409 keyword_generation_in_progress (batch-wide — nothing was written; retry after the workflow finishes).

GET /pages/{pageId}/content · S: sites:read

200 {data:{content_version_id: int|null, source: string|null, version_created_at: string|null, markdown: string}}. markdown = "" if none. source is which write path produced the served version (manual_edit, crawl, outline, action; null on snapshot fallback — other values may appear over time) — if a cached base_version_id starts 409ing, re-GET and check source/version_created_at to see what changed it. 404 Page not found.

POST /pages/{pageId}/content · S: content:write

Body: exactly one of markdown(string, ≤1,000,000 chars) or doc(ProseMirror object, ≤2,000,000 bytes serialized); optional base_version_id(int|null, optimistic-concurrency).
201 {data:{content_version_id, collapsed:false}} (new) or 200 {…collapsed:true} (merged into current).
Errors: 400 (neither/both provided; Provide either markdown or doc, not both; size/parse/invalid-doc), 404 Page not found, 409 Content was updated elsewhere (base_version_id mismatch).

GET /pages/{pageId}/analysis · S: sites:read

Latest non-skipped analysis. Query: compare (prev | an analysis id). → 200 {data: Analysis}.
Analysis: {id, page_id, site_id, snapshot_id|null, status, overall_score|null, overall_grade|null, estimated_score|null, estimated_grade|null, analyzed_content (object)|null, score_source, verified_at|null, content_stale, curve_shape, triggered_by|null, started_at|null, completed_at|null, created_at|null, updated_at|null, components: Component[], recommendations: Recommendation[]}.
analyzed_content is a JSON object (the content the analysis ran against), not a string: {hash, fields, sources?}. analyzed_content.sources is the provenance of what was actually scored — {title|meta_description|schema: {source: draft|snapshot|none, draft_id?}, body_content: {source: content_version|none, content_version_id?}}. Use it to answer "did my pending draft count?" — source: draft means it did. sources is absent on analyses that predate provenance recording (unknown, not "no drafts"), and isn't part of hash. estimated_score/estimated_grade use the additive model: overall_score + the sum of open recommendations' point_value. score_source is audit (produced by the analysis run) or verified (updated in place by verify-on-apply — see verified_at). content_stale: true means content changed after this analysis ran; treat the score as void until a re-analysis completes. curve_shape (flat|proportional) is the grade-curve formula this analysis was scored under — scores aren't comparable across curve shapes (recalibrated 2026-07-23).
Component: {id, analysis_id, component_type, label|null, status, score|null, grade|null, estimated_score|null, estimated_grade|null, criteria_scores, rationale|null, model|null, site_id, created_at, updated_at} (ordered by component_type). Component scores are indicative only — overall_score is the number of record.
Recommendation: {id, analysis_id, page_id, site_id, title|null, description|null, why_it_matters|null, impact|null, status, source|null, source_components, operation|null, target_field|null, target_node_ids, after_node_id|null, insertion_point|null, rubric_criteria, resolution_method|null, resolved_at|null, dismissal_rationale|null, sort_order|null, point_value|null, carried_from_recommendation_id|null, verification|null, content_excerpt|null, created_at, updated_at} (ordered by sort_order). No validation_result — that field is internal debug JSON; a redacted verification verdict ships instead.

  • status lifecycle: pending (open) → in_progress (change underway) → applied (change made, points earned) or dismissed (rejected, see dismissal_rationale).
  • point_value (int|null) — additive points this rec is worth; null predates the additive model.
  • carried_from_recommendation_id (int|null) — set on display-only history rows carried forward from a prior analysis (status applied); exclude these when summing this analysis's own work.
  • verification (object|null) — {addressed: boolean, reasoning: string, placeholders: string[], skipped: boolean, validation_scope: string|null, validated_node_ids: string[]|null}, null until verified. addressed: false means the applied change did NOT resolve the recommendation (no points earned). skipped: true means no validator verdict backs the pass (force-resolved, or the validator was unavailable) — it does NOT count toward the verified score; recheck to confirm. placeholders lists template fragments (e.g. "[Your Company]") to replace before publishing. validation_scope names the content the verdict was formed against (inline_content/targeted_nodes/full_document/field_draft/field_snapshot), or null when no scope was recorded — force-resolved, verified in-app outside this API, or written before scopes existed; validated_node_ids is set only for targeted_nodes.
  • content_excerpt (string|null) — markdown of the doc node(s) this rec targets, for locating the passage without ProseMirror ids. Populated for body_content-target recs when resolvable; null for metadata-target recs.

?compare=prev (or ?compare={analysisId}, any completed analysis of this page) adds comparison to data — what moved since that run, in one call instead of two-plus-a-diff. Without the param the key is absent, not null.
comparison: {previous_analysis_id, previous_analyzed_at|null, overall_score: {prev|null, now|null, delta|null}, overall_grade: {prev|null, now|null}, components: [{component_type, score: {prev, now, delta}}], criteria: [{component_type, key, label|null, prev, now, delta}], recommendations: {new, still_open, resolved, dropped}, matching: "best_effort_title"}. delta is now - prev, null if either side is missing (missing ≠ zero). criteria is the union of keys per component (key joins to the rubric); a key scored on one side only has null on the other. components/criteria list the current run's entries first, then earlier-run-only ones.
Rec buckets — new/still_open/resolved are CURRENT-analysis recs, dropped are earlier-analysis recs: new = current run only; still_open = both runs, still pending/in_progress (your edit didn't address it); resolved = both runs and now applied, or a carried history row; dropped = was open in the earlier run, not raised now. A rec you dismissed is in no bucket.
Matching is best-effort (matching: "best_effort_title"): nothing links a rec across runs, so they're paired on target_field + normalized title (lowercase, punctuation stripped). Wording churn still matches; a genuine rewrite reads as one dropped + one new. Summary, not a ledger. Deltas are only meaningful within one curve_shape.
comparison can be null (always 200), with comparison_note saying why: "no_previous_analysis" (compare=prev, first analysis of the page) or "analysis_incomplete" (the analysis being returned is queued/processing/failed, so it has no scores — poll the job or re-analyze first; the compare target isn't resolved in this case, so a bad id won't 404).
Errors: 400 invalid_request (compare neither prev nor a positive int), 404 Page not found, 404 No analysis found, 404 Comparison analysis not found (compare={id} isn't a completed analysis of this page).

GET /pages/{pageId}/analyses · S: sites:read

Score history — the page's COMPLETED analyses, newest first. Query: page (≥1, default 1), limit (1–20, default 10), include (criteria default | none). → 200 {data: Analysis[], meta: {total, page, per_page}}.
Each entry is the GET /analysis shape minus recommendations, with components attached unless include=none. Only completed runs appear (queued/failed/skipped have no score). A never-analyzed page is 200 with data: [], not a 404; only an unknown page 404s. Use ?limit=2 after a re-analysis to report the movement, not just the number. Only compare entries sharing a curve_shape (curve recalibrated 2026-07-23), and note that content_stale: true on the newest entry means its score describes content that has since changed.
Errors: 400 invalid_request (bad page/limit/include), 404 Page not found.

GET /pages/{pageId}/checks · S: sites:read

{data: PageChecks}. Deterministic hygiene checks on the page's CURRENT state — free, instant, no allowance, no LLM. Fields resolve exactly like an analysis run (active draft beats snapshot, blank draft falls back; body = newest content version), so sources (same shape as analyzed_content.sources) tells you whether your draft counted.
PageChecks: {page_id, checks: PageCheck[], summary: {pass, warn, fail, not_applicable}, fields: {title|null, meta_description|null, h1|null, word_count, primary_keyword|null}, sources, last_analysis: {id, overall_score, overall_grade, estimated_score, estimated_grade, analyzed_at}|null, content_changed_since_last_analysis: boolean|null}.
PageCheck: {key, status: pass|warn|fail|not_applicable, value: string|number|boolean|null, target|null, message}. Always all 15 keys, in this order: title_present, title_length(30-60), title_has_primary_keyword, meta_description_present, meta_description_length(120-160), meta_description_has_primary_keyword, h1_present, h1_single, h1_has_primary_keyword, intro_has_primary_keyword(first 100 words), word_count(300+), schema_present, schema_valid, internal_links(2+), outbound_links(informational).
not_applicable = the check couldn't run because a prerequisite is missing (no primary keyword, no body content version, no H1, no schema, no crawl) — each defect is reported once, so summary.fail counts distinct problems. A crawled page that links nowhere still reports internal_links: 0 (fail); not_applicable means "unknown".
These are hygiene, not the gradeoverall_score comes from the LLM grading against GET /sites/{siteId}/rubric. Passing every check does not imply a good grade. Link counts come from the latest crawl, so links you just wrote into a draft/content version don't appear until a recrawl.
content_changed_since_last_analysis: true = a re-analysis would see something new, false = it would likely be skipped, null = can't tell (never analyzed, or a legacy analysis with no fingerprint). 404 Page not found.

POST /pages/{pageId}/analyze · S: content:write

Body: {force?: boolean} (default false; unchanged content fingerprint without force → job completes skipped instead of a new analysis).
202 {data:{job_id, status:"queued", page_id, retry_after_seconds}}. Poll GET /jobs/{jobId} every retry_after_seconds (2s today).
Entitlement order: active-subscription page-allowance (402 payment_required, details:{limit,used,remaining}) → no-subscription non-homepage (402, no details) → no-subscription homepage free-preview exhausted (402, no details) → no-content (400 no_content) → already-running (409 analysis_in_progress).
triggered_by is null on key-triggered analyses.

POST /sites/{siteId}/analyze · S: content:write

Bulk form of the above — up to 50 pages of one site per call. Body: {page_ids?: int[] (1-50, unique, each ≥1), filter?: "priority", force?: boolean}exactly one of page_ids/filter (both or neither → 400); filter:"priority" takes the site's live priority pages, oldest id first, capped at 50.
always 202 {data:{jobs:[{page_id, job_id, retry_after_seconds}], skipped:[{page_id, reason, message}]}, meta:{requested, queued, skipped, truncated}}. requested === queued + skipped; an all-skipped batch is still 202, so branch on meta.queued. truncated: true = filter matched more than 50 (call again after these finish); always false for page_ids.
reasonnot_found (not a live page of this site) | no_content | analysis_in_progress | allowance_exhausted | payment_required | error (queuing failed; generic message, cause logged server-side — the only reason that can leave an analysis row behind, so it may have spent an allowance slot and a retry can 409 analysis_in_progress until that row clears; every other reason wrote nothing). Pages are queued one at a time with the allowance re-checked per page, so a batch larger than your remaining allowance partial-queues — the first N get jobs, the rest come back allowance_exhausted.
Errors (whole-call only): 400 (both/neither selector, >50/duplicate/non-positive ids, unknown filter, unknown key), 404 Site not found.

POST /pages/{pageId}/recrawl · S: content:write

Body: {new_path?: string(starts with /, ≤500)}. Requires active subscription (no free-preview carve-out).
202 {data:{job_id, status:"processing", page_id, retry_after_seconds}}. Poll GET /jobs/{jobId} every retry_after_seconds (5s today).
new_path migrates the page's url/path in place (same id) before queuing — always use this for a slug rename; a rename picked up by a routine full-site crawl instead creates a NEW page record and orphans the old one. Advances the page's content version (source:"crawl") and sets content_stale:true on the current analysis — any held base_version_id will 409 afterward.
Errors: 400 no_content (no URL) or 400 (invalid new_path), 402 payment_required (no subscription), 409 crawl_in_progress (a crawl is already running for the site), 409 path_conflict (new_path collides with another live page).

GET /pages/{pageId}/queries · S: sites:read

Query: days(1-365, default 28) or start_date+end_date. → {data: GscQueryRow[], gsc_status, window}. GscQueryRow: {query, clicks, impressions, ctr, position} (≤50 rows, by clicks). gsc_statusavailable|not_indexed|not_connected|unavailable — see Search Console data above. 400 on bad window params, 404 Page not found.

GET /pages/{pageId}/links · S: sites:read

{data: Link[], meta:{total, truncated}}. Latest crawl only — earlier crawls' rows are never merged in (legacy rows with no crawl id fall back to all rows). Ordered by position (document order), then id. Hard cap 1000 rows — check meta.truncated, not meta.total (which is just the returned-row count). Link: {id, href, anchor_text|null, target_page_id|null, target_path|null, context|null, location|null, position|null}. 404 Page not found.

GET /pages/{pageId}/outline · S: sites:read

{data: Outline}. 404 when the page has never had an outline generated (v1 convention — the internal dashboard route returns {data: null} instead). Outline: {id, page_id, status, primary_keyword|null, sections: OutlineSection[], serp_snapshot|null, error|null, triggered_by|null, created_at, completed_at|null, updated_at}. OutlineSection: {key, level(1|2|3), heading, guidance, source(serp|gap|own), status(proposed|accepted|revised|rejected), revised_heading|null, node_id|null}. serp_snapshot carries competitor heading trees only, never competitor body text. Creating/accepting outlines over the API ships in the next release — this endpoint is read-only for now.

GET /pages/{pageId}/performance · S: sites:read

Query: days(1-365, default 28) or start_date+end_date. → {data: GscDateRow[], totals: GscMetrics|null, gsc_status, window}. One row per day with data (no-data days are absent, not zero-filled), ascending. totals are undimensioned (accurate). Same gsc_status semantics as /queries. 400 on bad window params, 404 Page not found.

GET /pages/{pageId}/brief · S: sites:read

{data: Brief}. The page work packet — page summary, resolved fields (title/meta/h1/body markdown, plus schema with a validation verdict), latest analysis + components, open recommendations with content_excerpt, keywords with cached metrics, 28-day queries, an outline summary, and links — one call instead of six. Sections degrade independently (analysis: null, queries.gsc_status: "unavailable", etc.); only the page itself 404s. fields.body.value can be large (up to the 1,000,000-char save limit). See Discovery above and brief.md for the full section-by-section breakdown.

GET /pages/{pageId}/report · S: sites:read

{data: Brief & {comparison, comparison_note, serp}}. Everything the brief returns, plus comparison (score/component/criterion deltas + a best-effort recommendation diff against the previous completed analysis; null with comparison_note set to no_previous_analysis/analysis_incomplete when there's nothing to compare) and serp ({analysis, snapshot, source} — the current analysis's serp_analysis component plus the latest outline's SERP snapshot; no live SERP fetch happens here). This is the one-call read of an analyze run's result — the MCP analyze_page/get_page_report tools return exactly this shape. Sections degrade independently; only the page itself 404s. See report.md for the full breakdown and when to prefer this over /brief.

Snapshots (S: sites:read)

GET /pages/{pageId}/snapshots

Query: page, limit(default 20). → {data: SnapshotLean[], meta}. 404 Page not found.
SnapshotLean: {id, title|null, meta_description|null, h1|null, word_count|null, status_code|null, created_at|null}.

GET /snapshots/{snapshotId}

{data: SnapshotFull}. 404 Snapshot not found (missing or foreign page).
SnapshotFull: {id, page_id|null, url|null, path|null, title|null, meta_description|null, h1|null, word_count|null, status_code|null, status|null, schema, content: string, created_at|null, updated_at|null}. content = markdown ("" if malformed).

Content versions (S: sites:read)

Version history for a page's body content. Ids are NOT append-only — consecutive manual edits collapse into one version (see collapsed on POST /pages/{pageId}/content); treat the list as a snapshot, not a ledger. Sources: manual_edit (editor/API), crawl (recrawl), outline (accepted outline sections), action (AI action).

GET /pages/{pageId}/content-versions

Query: page, limit(default 20). Newest first, no doc bodies. → {data: ContentVersion[], meta}. 404 Page not found.
ContentVersion: {id, source, status, word_count|null, created_at, updated_at}. word_count is populated for every source (crawl-sourced versions included); null only on rows written before the field existed.

GET /pages/{pageId}/content-versions/{versionId}

{data: ContentVersion & {body_markdown: string}}. The ProseMirror doc never leaves the API — only rendered markdown. body_markdown = "" if the stored doc is malformed/empty.
404 Page not found or 404 Content version not found.
Rollback recipe: GET the old version for body_markdown, GET /pages/{pageId}/content for the current content_version_id, then POST /pages/{pageId}/content with that markdown and base_version_id set to the current id.

Recommendations (S: content:write)

Act on individual recommendations from GET /pages/{pageId}/analysis by recId. All three verbs return {data:{recommendation: Recommendation, analysis: RecVerbAnalysis|null}}RecVerbAnalysis: {overall_score|null, overall_grade|null, estimated_score|null, estimated_grade|null, score_source, verified_at|null}, re-read AFTER this call's score recalculation. analysis is null only if the rec had no analysis_id.

POST /recommendations/{recId}/dismiss

Body: {rationale?: string(≤2000)}. → 200 {data:{recommendation, analysis}}. recommendation.content_excerpt is always null here (dismissing never resolves content).
Errors: 404 Recommendation not found, 409 already_resolved (already dismissed/applied).

POST /recommendations/{recId}/resolve

Body: {content?: string(≤1000000), force?: boolean}. body_content recs auto-resolve content (targeted nodes, always with the full document sent alongside as context → full doc alone) when content is omitted; title/meta_description/h1/schema recs auto-resolve too — the field's active draft, falling back to the latest crawled snapshot value (see field-content.ts). force:true skips validation, marks applied immediately (skipped:true, validation_scope:null).
200 {data:{addressed: boolean, reasoning: string, skipped: boolean, placeholders: string[], validation_scope: "inline_content"|"targeted_nodes"|"full_document"|"field_draft"|"field_snapshot"|null, validated_node_ids: string[]|null, recommendation, analysis}}. On addressed:true, status→applied, resolution_method:"manual", scores recalculate. On addressed:false, only verification is updated — status/points unchanged. skipped:true counts toward estimated_score but NOT the verified overall_scorerecheck to promote.
Errors: 400 no_content_to_validate (nothing to validate against — no content and no draft/snapshot value for the field; rec stays open, nothing written), 404 Recommendation not found, 409 already_resolved.

POST /recommendations/{recId}/recheck

No body. Only valid on an applied rec — that is the ONLY gate, so a skipped:true rec is re-checkable and a real verdict clears the flag. Re-verifies (content priority: validation_result.applied_node_idstarget_node_ids → full doc/current draft, falling back to the snapshot for metadata/schema targets; node-scoped reads also ship the full document as context). A clean recheck is what promotes overall_score/score_source to verified if the original apply had placeholders or was skipped. recheck never changes status: on addressed:false the rec stays applied and only validation_result is rewritten — but it stops counting as verified, so its points come back out of the promoted overall_score (and out of estimated_score, since a failed verdict isn't an earnable open gain).
200 {data:{addressed, reasoning, skipped, placeholders, validation_scope, validated_node_ids, recommendation, analysis}} — the same verdict block as resolve.
Errors: 400 no_content_to_validate, 404 Recommendation not found, 409 not_applied (rec isn't currently applied).

Jobs (S: sites:read)

Virtual async-job resource returned by analyze/recrawl triggers — no jobs table, job_an_{id} reads a page_analyses row and job_cr_{id} reads a crawls row directly.

GET /jobs/{jobId}

200 {data: Job}. Job: {id, type:"analyze"|"recrawl", status, page_id|null, site_id, created_at, started_at|null, completed_at|null, error|null, retry_after_seconds|null, result}.
statusqueued|processing|completed|failed|skipped (skipped = analyze only, unchanged content fingerprint, no new analysis — re-trigger with force:true for a fresh one). Poll until it leaves queued/processing, sleeping retry_after_seconds between calls — honour it, don't hardcode. It's null exactly when the job is terminal (your stop signal) and repeated in a Retry-After header while in flight. This endpoint spends its own rate-limit bucket (~600/60s), separate from the rest of the API.
page_id is null on recrawl jobs until completed (resolved from the crawl's snapshot). error is generic text ("Analysis failed"/"Crawl failed"/"Crawl cancelled" — a cancelled crawl reports status:"failed", there's no cancelled status), never raw internals.
result is {} until completed. analyze: {analysis_id, overall_score, overall_grade, score_source}. recrawl: {snapshot_id}null means the live page returned an error response and no snapshot was written (completed ≠ content updated).
404 not_found for any malformed/missing/foreign job id (Job not found, identical for all three cases).

GET /jobs?ids=job_an_1,job_cr_2

Batch poll, 1–50 ids — use this instead of looping the single endpoint, especially after POST /sites/{siteId}/analyze.
200 {data: (Job | {id, status:"not_found"})[], meta:{requested, found}}. One entry per id in request order; ids are trimmed and deduped (first position wins) and the 1–50 cap applies after dedupe, so meta.requested === data.length. A malformed/missing/foreign id is a not_found entry inside the 200 — one bad id never fails the batch, and there's no 404 here.
No Retry-After header (a batch mixes terminal and in-flight jobs): sleep the largest non-null retry_after_seconds in data, then re-poll only the ids that still have one. not_found is terminal too. Same jobs rate-limit bucket as the single endpoint.
400 invalid_request if ids is missing/empty or has more than 50 ids after deduping.

Drafts

fieldtitle, meta_description, h1, primary_keyword, body, image_alt, schema. Other → 400.

GET /pages/{pageId}/drafts · S: sites:read

All drafts, every status, ordered by field. Not paginated.200 {data: Draft[]}. 404 Page not found.
Draft: {id, field, status, original_value|null, draft_value|null, published_at|null, created_at|null, updated_at|null}.

PUT /pages/{pageId}/drafts/{field} · S: content:write

Body: {draft_value: string(≤20000)} (required).
201 {data: Draft} (created) or 200 {data: Draft} (overwrote existing active draft, set to draft status). For title/meta_description/h1/schema, new drafts seed original_value from latest snapshot. For field=schema, the response also carries a top-level validation: {valid, errors[], warnings[], summary} (advisory — schema.org rule violations don't block the save, only unparseable JSON does); a blank draft_value clears the field and skips validation (no validation key on that response).
Errors: 400 (bad field / missing/oversized draft_value / field=schema with unparseable JSON — details.draft_value explains the parse error), 404 Page not found.

DELETE /pages/{pageId}/drafts/{field} · S: content:write

Deletes only pending/draft-status drafts — a generating/failed/published/resolved row survives untouched, and the response is still 204, so a success can be a no-op. To take over a stuck slot, PUT a new value instead. Errors: 400 (bad field), 404 Page not found.

Keyword metrics (S: sites:read)

GET /keywords/metrics

Query: keyword(required, 1-200), location(int, default 2840 = US), language(string, default en). Global shared cache — not org-scoped, never fetches live.
200 {data:{keyword, search_volume|null, keyword_difficulty|null, cpc|null, competition|null, search_intent|null, monthly_searches|null}}.
Errors: 400 No keyword provided, 404 Keyword metrics not found (cache miss).

Quick recipes

BASE=https://new.metamonster.ai/api/v1
H="Authorization: Bearer $METAMONSTER_API_KEY"

curl -s "$BASE/me" -H "$H"                                            # verify key
curl -s "$BASE/sites" -H "$H"                                         # list sites
curl -s "$BASE/sites/42/pages?is_priority=true&min_score=50&sort=opportunity_score&direction=desc" -H "$H"
curl -s "$BASE/pages/5001" -H "$H"                                    # page detail
curl -s "$BASE/pages/5001/content" -H "$H"                            # body as markdown
curl -s -X PATCH "$BASE/pages/5001" -H "$H" -H "Content-Type: application/json" \
  -d '{"primary_keyword":"example pricing","is_priority":true}'
curl -s -X PATCH "$BASE/sites/42/pages" -H "$H" -H "Content-Type: application/json" \
  -d '{"page_ids":[5001,5002,5003],"set":{"is_priority":true}}'      # bulk, ≤50 pages
curl -s -X PUT "$BASE/pages/5001/drafts/title" -H "$H" -H "Content-Type: application/json" \
  -d '{"draft_value":"Example Pricing: Plans & Costs"}'
curl -s "$BASE/keywords/metrics?keyword=example%20pricing" -H "$H"
curl -s "$BASE/sites/42/rubric" -H "$H"                               # what the grader measures (free)
curl -s "$BASE/pages/5001/checks" -H "$H"                             # mechanical misses right now (free)
curl -s "$BASE/pages/5001/analysis?compare=prev" -H "$H"              # new scores + what moved since the last run
curl -s "$BASE/pages/5001/analyses?limit=5" -H "$H"                   # score history, newest first

# Trigger + poll an analysis
JOB=$(curl -s -X POST "$BASE/pages/5001/analyze" -H "$H" -H "Content-Type: application/json" -d '{"force":true}' | jq -r .data.job_id)
curl -s "$BASE/jobs/$JOB" -H "$H"                                     # repeat every data.retry_after_seconds until status leaves queued/processing

# Trigger + poll a whole batch (one poll for up to 50 jobs)
IDS=$(curl -s -X POST "$BASE/sites/42/analyze" -H "$H" -H "Content-Type: application/json" -d '{"filter":"priority"}' | jq -r '[.data.jobs[].job_id] | join(",")')
curl -s -G "$BASE/jobs" --data-urlencode "ids=$IDS" -H "$H"            # repeat until every entry has retry_after_seconds null (or status "not_found")

# Resolve a recommendation, then recheck after clearing a placeholder
curl -s -X POST "$BASE/recommendations/5501/resolve" -H "$H" -H "Content-Type: application/json" -d '{}'
curl -s -X POST "$BASE/recommendations/5501/recheck" -H "$H"