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), DELETE /pages/{id}, POST /pages/{id}/content, content-version restore, PUT/DELETE /pages/{id}/drafts/{field}, POST /pages/{id}/analyze (+ bulk POST /sites/{id}/analyze), POST /pages/{id}/recrawl, the recommendation verbs (dismiss/resolve/recheck), AI generation (briefs, fixes, body rewrites, field drafts, alt text), image alt writes, and Context Library / fact writes
cms:publish Reserved; no public endpoint uses it yet

Dashboard-minted keys carry all three. A missing scope returns 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 returns { "ok": true }; GET /me returns { "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), recommendations_in_progress (409, checks recommendations are still being written; read them shortly instead of regenerating), 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, the page's 15 AI generations for its current analysis are used up; re-run the analysis for a fresh set; distinct from rate limiting), engine_paused (503, the preview checks engine is paused for maintenance; nothing was queued, retry later), internal_error (5xx). The generation, image and Context Library endpoints add their own codes, listed with each endpoint in the AI generation and Context Library sections; page_without_site (409) and no_acting_user (409, no member of the organization to attribute the work to) can come back from any of them.
  • 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. The default bucket is ~120 req/60s; a separate ~600 req/60s bucket covers job polling (GET /jobs/{jobId} and GET /jobs?ids=) so polling never eats your general budget; and a small ~6 req/60s llm bucket covers the routes that start an LLM generation: POST /pages/{pageId}/outline, POST /pages/{pageId}/actions, POST /recommendations/{recId}/fix, POST /actions/{actionId}/revise, POST /pages/{pageId}/content-rewrites, POST /pages/{pageId}/drafts/{field}/generate and POST /pages/{pageId}/checks/recommendations. 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 finds the site (it 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 (a 404 tells you whether to POST /sites or POST /sites/{siteId}/pages if it doesn't exist yet).
  2. GET /sites/{siteId}/pages finds pages, with rich filters: search, is_priority, has_keyword, has_draft, has_recommendations, grade (or min_factors_passing_pct for organizations on the new analysis), 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 (a 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} returns full page detail (it embeds the latest snapshot, an analysis summary, and active drafts).
  5. Then, as needed: GET /pages/{pageId}/content (body as markdown), /analysis (scores incl. estimated_score/estimated_grade plus 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 into one call: fields incl. schema with validation, analysis with estimated_score, recommendations, keywords, queries, outline, and links; 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 refreshes 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 (the call responds 202 + job instead of 201). This is 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 for a new version, or 200 if it 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). field ∈ title, 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. MovingCompany → LocalBusiness, TechArticle → Article). A blank draft_value clears the field and skips validation.

None of these write endpoints run AI generation. They store exactly what you send. For AI generation, see AI generation.

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 use 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 with 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 it after edits made directly on the site, not through this API). Requires an active subscription, with 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.

AI generation (needs content:write)

The app's AI features, over the API. Nothing is published: generated text lands as a draft, a content version or an alt draft for review. The async ones answer 202 with a job (job_ol_ brief, job_ac_ fix, job_rw_ body rewrite) that you poll like any other.

  • Content brief. POST /pages/{pageId}/outline (no body) generates a brief from the page's primary keyword and SERP: an angle plus proposed sections. 400 primary_keyword_required without a keyword; 402 payment_required past the plan allowance (on the free preview, only the homepage and one brief). A brief already generating is not an error: the 202 returns that run's job. Poll the job, then GET /pages/{pageId}/outline. Review it with PATCH /pages/{pageId}/outline/sections (see the reference); accepting inserts the headings into the body and returns a content_version_id. Write the section text next with POST /pages/{pageId}/content, passing that id as base_version_id.
  • AI fix for one recommendation. POST /recommendations/{recId}/fix (or POST /pages/{pageId}/actions with {recommendation_id}, or {target_field, prompt} for a fix without a rec). Poll the job_ac_ job, then GET /actions/{actionId} for the before/after, then POST /actions/{actionId}/accept, /reject, or /revise with {instructions}. Accepting saves a metadata fix as the field's draft and a body fix as a new content version, and marks the rec applied. Capped at 15 generations per page per analysis, revisions included (429 generation_cap_reached). Image alt recs are refused (400 unsupported_target_field); use the image endpoints.
  • Body rewrite from several body recommendations. POST /pages/{pageId}/content-rewrites with {recommendation_ids, instructions?}. No accept step: when the job_rw_ job completes, the rewritten body is already the page's latest content version, and the job result names it (content_version_id) plus which recs were applied. 1 credit.
  • Field draft. POST /pages/{pageId}/drafts/{field}/generate (title, meta_description or h1; optional {instructions}), synchronous; returns the new Draft. Costs credits like the app.
  • Alt text. GET /pages/{pageId}/images, then PUT /pages/{pageId}/images/alt ({src|image_id, alt}, no AI) or POST /pages/{pageId}/images/generate-alt (one image per call, synchronous, credits).

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 }. This means "not doing this." 409 already_resolved if already dismissed/applied.
  • POST /recommendations/{recId}/resolve: body { content?: string, force?: boolean }. This means "I made this change." It AI-validates (Haiku) the current content against the recommendation; on a pass, it marks the rec 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) then resolve with no body. There's 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; it never blocks on a telemetry check). A skipped verdict does NOT count toward the verified score; recheck to confirm it. The 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-verifies 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. The 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.

Organizations on the new analysis (factors)

Some organizations (rolled out gradually) are on the new analysis. Check every analysis-bearing response for engine: "checks". When it is present, the response is factor-based and the score/grade guidance elsewhere in this file does not apply; when it is absent, the legacy scored shapes described below are unchanged. For engine: "checks" responses:

  • Never report a score or a grade. There is none: no overall_score, overall_grade, estimated_score, estimated_grade, score_source, components or point_value. Report factors: 36 of 41 factors passing from factors_passing / factors_total (pass / pass + warn + fail; not_applicable excluded; both null when no factor applies, which means "no factors apply", not 0/0).
  • GET /pages/{id}/analysis returns the factors, checks and recommendations of the latest checks analysis; compare is ignored and GET /pages/{id}/analyses lists factor history (include is ignored). A page with only a legacy analysis is 404 No analysis found: analyze it.
  • GET/PATCH /pages/{id}, /brief and /report embed analysis: { id, analyzed_at, factors_passing, factors_total, recommendations_status } (null when the page has none). /report has comparison: null.
  • POST .../analyze returns job_ck_ job ids; poll them the same way. The bulk trigger can skip a page with engine_paused.
  • recommendations_status can be not_requested: the findings are in but no recommendations were written. Call POST /pages/{id}/checks/recommendations (MCP: write_recommendations), then read GET /pages/{id}/analysis (or /brief) once the status is ready. generating means wait; failed means a templated fallback list shipped.
  • Find work with GET /sites/{id}/pages?min_factors_passing_pct=80 (a grade filter is a 400) and GET /sites/{id}/opportunities, whose analysis block and per-row analysis replace grade. A row's score is the demand / opportunity rank, not an analysis result.
  • GET /sites/{id}/rubric returns the factor list. The /checks/* endpoints stay as aliases.
  • MCP: use analyze_page, get_page_report, write_recommendations, get_rubric and find_pages (min_factors_passing_pct); the checks_* tools are not available to these organizations.

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 shows 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 shows 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. It's 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 13 checks and still grade a C, because 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, since 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. An image_alt rec is fixed per image: PUT /pages/{pageId}/images/alt or POST /pages/{pageId}/images/generate-alt, then resolve the rec.
  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), which is 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 completed checks analysis may report recommendations_status: generating; poll GET /pages/{pageId}/checks/analysis until ready or failed; its recommendations field then carries the list (it is [] while generating).
  • POST /pages/{pageId}/checks/recommendations (rewrite a checks analysis's recommendations) is asynchronous: it returns 202 {data: {analysis_id, page_id, recommendations_status: "generating"}} at once; poll GET /pages/{pageId}/checks/analysis as above (about a minute). A failed rewrite changes nothing and restores the previous status.
  • 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: the ranked "what should I work on" report, with opportunities[] (scored pages, with per-row recommendations[]), a flat cross-page recommendations[] (up to 8 highlights), and GSC/keyword-opportunity summaries. Read-only; it 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. It bundles the page 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), the latest analysis with components and 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. Generate one with POST /pages/{pageId}/outline (see AI generation).

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, DELETE /pages, the recommendation verbs, AI generation, and triggering analyze/recrawl all mutate customer data or spend allowance or credits. 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. For research that does fetch live (seed metrics plus related ideas), use GET /keywords/research?keyword=... (optionally site_id for the site's market). A repeat of the same keyword and market within 30 days is served from cache for free (meta.cached: true); other lookups are paid: capped at 500 per organization per day (shared with the dashboard; 429 keyword_research_cap_reached with details.resets_at) and 30 per minute per key; meta.daily_lookups.remaining shows what's left.

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); it's 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.

Site planning · S: content:write (GET needs sites:read)

For a site that isn't live yet (reference). POST /sites/plan {brief, name?} → 201 {data: SiteDetail} (no domain, no crawl; llm bucket). POST /sites/{siteId}/plan/generate {regenerate?, brief?} → {data: {pages: [{id, name, slug, purpose}], brief}} (idempotent; 10-30s when it generates; llm bucket). Edit the set with POST /sites/{siteId}/plan/pages {name, slug?, purpose?}, PATCH/DELETE /sites/{siteId}/plan/pages/{pageId}. POST /sites/{siteId}/plan/keywords → 202 assigns primary keywords (spends credits per page); poll GET /sites/{siteId}/plan → {data: {total, with_keyword, failed, done, keywords_running, crawl_status|null, pages: [{id, name, path, purpose, primary_keyword|null, failed}]}}. Once published, POST /sites/{siteId}/go-live {domain} → 202 {data: {crawl_id, status, domain}} crawls it onto the planned pages (409 if already live).

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 1000; meta.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_status ∈ available|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; it 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?}. source ∈ manual|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). status ∈ active|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). status ∈ active|gone; gone 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. reason ∈ invalid|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?, verified?: true, status?: "active"}. To edit, send data (validated against the fact's existing kind); the fact is marked user-edited so extraction leaves it alone. To confirm a stale fact is still true without changing it, send {verified: true, status: "active"} and no data. → {data: SiteFact}. 409 fact_not_stale when the fact being confirmed isn't stale (a dismissed fact is never revived here); 409 fact_kind_not_editable for a value edit on an extractor-owned kind (offer, pricing, capability, integration), which can still be confirmed or deleted.

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; 400 on the new analysis), min_factors_passing_pct(0-100; new analysis only), 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(≤500), title?, crawl?: boolean (default false), purpose?, primary_keyword?, secondary_keywords?}, with path or title required. Creates a page on the site, like the app's "Add page" (path normalized to lowercase/leading-slash; without one, the path is the slugified title). 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 #fragment; 400 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).

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

→ 200 {data: {id, deleted: true}}. A soft delete for pages created by mistake (e.g. a mistyped URL): the page leaves every listing and lookup, nothing is cascaded, and the live site is untouched. Adding the same URL again later creates a fresh page. 404 page_not_found for a missing, foreign or already-deleted page.

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, review_mode, restore; 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 13 keys, in this order (title/meta length checks were removed 2026-10-01: length is display, not ranking): title_present, title_has_primary_keyword, meta_description_present, 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 grade; overall_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.
reason ∈ not_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_status ∈ available|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, angle|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}. angle: {thesis, serp_pattern, differentiators: [{title, detail}], pov_prompts: string[]} (null on outlines from before briefs carried one). error is the generic "Outline generation failed" on a failed outline. serp_snapshot carries competitor heading trees only, never competitor body text.

POST /pages/{pageId}/outline · S: content:write · llm bucket

No body. → 202 {data: {job_id: "job_ol_N", outline_id, status: "queued"|"processing", retry_after_seconds}}. Generates a content brief from the primary keyword's SERP; counts as a page against the plan allowance. A brief already generating returns that run's job (not an error). Regenerating creates a new outline; headings rejected on the previous one steer it. Errors: 400 primary_keyword_required, 402 payment_required, 409 page_without_site.

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

Body: {order?: string[], sections?: [{key, status?: "accepted"|"rejected"|"proposed", revised_heading?}]} (at least one). revised_heading makes the section revised (accepted with the new text), including when sent with status: "accepted"; status: "accepted" without a heading on a revised section makes it plain accepted, which reverts it to the original heading. order reorders; unnamed keys keep their order after the named ones. → {data: {outline: Outline, headings_inserted, content_version_id|null}}. Newly accepted headings are inserted into the body as a new content version (content_version_id); write the sections with POST /pages/{pageId}/content using it as base_version_id. Errors: 400 unknown_section (details.keys; nothing applied), 404 (no outline), 409 outline_not_ready (details.status: pending/processing/failed).

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 (an accepted AI fix), review_mode (a body rewrite), restore (a restored version).

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 does. body_markdown = "" if the stored doc is malformed/empty.
404 Page not found or 404 Content version not found.
Rollback: POST /pages/{pageId}/content-versions/{versionId}/restore (below). Or by hand: 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.

POST /pages/{pageId}/content-versions/{versionId}/restore · S: content:write

No body. → {data: {version_id, created_at, restored_from_version_id, restored_from_created_at}}. Copies the old version forward as a NEW version (source restore) and points the page at it; nothing is deleted, applied recs are left alone, nothing is published. Errors: 400 version_already_current, 404 content_version_not_found.

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_score; recheck 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_ids → target_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 the async triggers; no jobs table. job_an_{id} / job_ck_{id} read a page_analyses row, job_cr_{id} a crawls row, job_ol_{id} an outline (content brief), job_ac_{id} an action (AI fix, or its revision), and job_rw_{id} a body rewrite.

GET /jobs/{jobId}

→ 200 {data: Job}. Job: {id, type:"analyze"|"recrawl"|"checks"|"outline"|"action"|"rewrite", status, page_id|null, site_id, created_at, started_at|null, completed_at|null, error|null, retry_after_seconds|null, result}.
status ∈ queued|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). outline: {outline_id}. action: {action_id}; an action job is completed once the fix is generated, whether it is then awaiting review, accepted, rejected or superseded by a body rewrite (read GET /actions/{actionId} for that). rewrite: {rewrite_id, content_version_id, recommendation_ids, applied_recommendation_ids, unresolved_recommendations: [{recommendation_id, reason, already_satisfied}]}. Generation jobs have started_at: null and generic errors ("Outline generation failed", "Action generation failed", "Rewrite failed").
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

field ∈ title, 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.

POST /pages/{pageId}/drafts/{field}/generate · S: content:write · llm bucket

field ∈ title, meta_description, h1. Body: {instructions?: string(≤2000)}. Synchronous. → {data: Draft}, replacing any unpublished draft. Uses the site's prompt settings for the field; instructions add one-off guidance. Charges credits like the app (402 payment_required); not counted toward the generation cap. 409 page_without_site.

AI fixes (actions)

An action is one AI-generated change for one recommendation (or field), awaiting review. Status: generating → reviewing → accepted | rejected; also failed, and superseded (a body rewrite applied its rec and closed it).

POST /recommendations/{recId}/fix · S: content:write · llm bucket

No body. → 202 {data: {job_id: "job_ac_N", action_id, status: "processing", retry_after_seconds}}. Everything (field, prompt, body targets) comes from the rec. Same errors as below.

POST /pages/{pageId}/actions · S: content:write · llm bucket

Body: {recommendation_id} or {target_field: "title"|"meta_description"|"h1"|"schema"|"body_content", prompt, operation?: "insert"|"replace"|"delete", insertion_point?, after_node_id?, target_node_ids?}; explicit fields override the rec's. A body_content fix without a rec needs after_node_id or target_node_ids. → 202, same shape as above. Errors: 400 unsupported_target_field (image alt recs; use the image endpoints), 400 planned_page_needs_content, 409 recommendation_not_actionable (rec not pending/in progress/failed), 409 page_without_site, 429 generation_cap_reached ("This analysis's 15 free generations are used up. Re-run the analysis to get a fresh set."; revisions count too, and the window resets when an analysis on the org's current engine completes).

GET /actions/{actionId} · S: sites:read

→ {data: Action}. Action: {id, job_id, page_id, site_id, recommendation_id|null, target_field, operation|null, prompt, status, created_at, updated_at, resolved_at|null, attempts, content_format: "markdown"|"json"|"text", latest_attempt: {attempt_number, status, instructions|null, original_content|null, generated_content|null, completed_at|null}|null, target_content|null}. latest_attempt.original_content is the "before" (the whole body as markdown for body fixes); generated_content is the proposed change. target_content is the markdown of just the targeted nodes in the current body (body replace/delete fixes not yet accepted), a focused "before".

POST /actions/{actionId}/accept · S: content:write

No body. → {data: {action_id, page_id, status: "accepted", target_field, content_version_id|null, draft_value|null, recommendation: {id, status: "applied"|null}|null}}. A metadata fix becomes the field's draft (draft_value); a body fix becomes a new content version (content_version_id). recommendation.status: null means the change was applied but the rec couldn't be marked applied; resolve it yourself. 409: action_not_reviewing (still generating, or already decided), action_not_ready, duplicate_content or target_content_changed (regenerate for the last two).

POST /actions/{actionId}/reject · S: content:write

No body. → {data: {action_id, page_id, status: "rejected", recommendation: {id, status: "pending"|null}|null}}. Nothing is written; the rec goes back to pending. 409 action_not_reviewing.

POST /actions/{actionId}/revise · S: content:write · llm bucket

Body: {instructions: string(1-2000)}. → 202 {data: {job_id: "job_ac_N", action_id, status: "processing", retry_after_seconds}} (the action's own job id). Counts toward the generation cap. 409 action_not_reviewing.

Body rewrites

POST /pages/{pageId}/content-rewrites · S: content:write · llm bucket

Body: {recommendation_ids: int[](1-50), instructions?: {"<recId>": string}}. → 202 {data: {job_id: "job_rw_N", rewrite_id, status: "processing", recommendation_ids, retry_after_seconds}}; recommendation_ids is the subset kept (resolved and non-body recs are dropped). When the job completes the rewritten body is already the latest content version (no accept step) and the applied recs are applied; the job result carries content_version_id and per-rec outcomes. Undo with restore. 1 credit. Errors: 400 no_content, 400 no_actionable_recommendations, 402 payment_required, 409 page_without_site.

Images

Alt text is stored per image src across the site, not as a page field.

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

→ {data: PageImage[], meta: {total, missing_alt}}, in page order. PageImage: {id, src, original_alt|null, draft_alt|null, draft_status|null, alt|null, missing_alt, decorative, width|null, height|null, position|null}. original_alt is null when the tag has no alt attribute and "" for alt="" (decorative: true, still counted as missing). alt is the one that counts: a non-blank draft, else the crawled alt.

PUT /pages/{pageId}/images/alt · S: content:write

Body: {src | image_id, alt: string(≤1000)} (exactly one of src/image_id). No AI. → {data: PageImage}. Errors: 404 image_not_found (not on this page), 409 page_without_site.

POST /pages/{pageId}/images/generate-alt · S: content:write

Body: {src? | image_id?}; neither = the first image still missing alt. Synchronous, one image per call, default rate bucket. → {data: PageImage, meta: {missing_alt_remaining}}. Charges credits (402 payment_required); not counted toward the generation cap. Errors: 404 image_not_found, 409 no_missing_alt, 409 page_without_site.

Context Library

What every generation is told about how to write and what to recommend. Scans and regenerations run in the background with no job resource: re-read the guidance or facts later.

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

→ {data: {voice_tone|null, business_context|null, audiences: [{offer: {id, name, type}, rows: AudienceRow[]}], entries: GuidanceEntry[], facts_extracted_at|null, extraction_status: "running"|"idle"}}. GuidanceEntry: {id, kind: "preference"|"voice", body, page_id|null, source, created_at, updated_at} (preference = what to recommend and add; voice = how to write; page_id: null = site-wide). AudienceRow: {id, offer_fact_id, role: "for"|"also"|"not_for", segment, source: "generated"|"manual", status: "active"|"stale", user_edited, position, citations[]}.

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

Body: {voice_tone?, business_context?} (strings ≤20000, at least one). → {data: {id, voice_tone, business_context}}. A non-empty value marks the field user-edited, so automatic context generation leaves it alone; "" clears it.

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

Body: {kind, body: string(1-2000), page_id?: int|null}. → 201 {data: GuidanceEntry} (source: "api"). 400 when page_id isn't a page on this site.

PATCH /sites/{siteId}/guidance/{guidanceId} · S: content:write

Body: {body?, page_id?: int|null, status?: "active"|"archived"} (at least one). → {data: GuidanceEntry}. Archived entries aren't given to the generator. 404 guidance_not_found.

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

Body: {offer_fact_id, role, segment: string(1-120)}. → 201 {data: AudienceRow} (manual, never replaced by regeneration). 400 when the offer isn't an active offer on this site; 409 duplicate_audience.

PATCH/DELETE /sites/{siteId}/audiences/{audienceId} · S: content:write

PATCH body {role?, segment?} → {data: AudienceRow} (marked user-edited; a stale row becomes active). DELETE → {data: {id, deleted: true, mode: "dismissed"|"deleted"}} (an untouched generated row is dismissed so regeneration doesn't bring it back). 404 audience_not_found, 409 duplicate_audience.

POST /sites/{siteId}/audiences/regenerate · S: content:write

→ 202 {data: {queued: true}}. Replaces generated rows only. 409 extraction_in_progress while a fact scan runs.

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

→ 202 {data: {queued: true}}, or 202 {data: {queued: false, reason: "already_running"}}. Rescans the site's pages for facts (and audiences).

GET/PUT /sites/{siteId}/facts/settings

GET (S: sites:read) → {data: {skip_paths, instructions|null, page_paths}} (page_paths: up to 1000 crawled paths to pick patterns from). PUT (S: content:write) body {skip_paths: string[](≤50), instructions: string|null(≤2000), rescan?: boolean}, replacing both → {data: {skip_paths, instructions, queued, pass: "settings"|"all"|null, reason?: "already_running"}}. 400 for an invalid pattern, including /*.

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

Body: {listing_url?} (a Google Maps / Business Profile URL). → 202 {data: {queued, reason?}}. Refreshes only the Google listing fact.

GET /sites/{siteId}/dismissal-routing/facts · S: sites:read

→ {data: [{rec_id, page_id, page_path|null, rec_title, reason|null, action: "add"|"update"|"retire", fact_id, fact_kind, fact_label|null, routed_at}]}. The newest 20 fact changes made from dismissal reasons.

POST /recommendations/{recId}/dismissal-routing/undo · S: content:write

→ {data: {recommendation_id, undone: true}}. Reverses the fact or guidance change the dismissal reason made; never overwrites a later human edit. 404 no_dismissal_routing, 409 undo_not_possible.

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"