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 /mebefore 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" }(nodata);GET /jobs?ids=is a list but a lookup by id (meta: {requested, found}), andGET /pages/{id}/linksis a capped dump (meta: {total, truncated},total= returned rows) — neither takespage/limit. Complete-but-unpaginated arrays:GET /pages/{id}/drafts, thechecksarray inGET /pages/{id}/checks, andcomponents/recommendationsinsideGET /pages/{id}/analysis. - Errors return
{ "error": { "code", "message", "details?" } }with an HTTP status — on every endpoint,/meand/healthincluded.detailsis an optional object with machine-readable context for specific codes (e.g.payment_requiredcarries{ limit, used, remaining }). Switch oncode: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, recrawlnew_pathcollides with another page),already_resolved(409, recommendationdismiss/resolveon an already-dismissed/applied rec),not_applied(409,recheckon a rec that isn't currentlyapplied),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, not403— 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 untilpage * 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}andGET /jobs?ids=) so polling never eats your general budget, and a small ~6 req/60s bucket forPOST /pages/{pageId}/checks/recommendations(a model call per request, no allowance debit). Response headersX-RateLimit-Limit/-Remaining/-Resetdescribe whichever bucket the request spent. On429, honor theRetry-Afterheader. Failed-auth requests are throttled separately per IP — if you see429 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.
GET /sites— find the site (hasid,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 toPOST /sitesorPOST /sites/{siteId}/pagesif it doesn't exist yet).GET /sites/{siteId}/pages— find pages. Rich filters:search,is_priority,has_keyword,has_draft,has_recommendations,grade,min_score/max_score,sort,direction.- Or skip browsing and let the site tell you what to work on:
GET /sites/{siteId}/opportunities→ pick apage_idfromopportunities[](or the flat cross-pagerecommendations[]) →GET /pages/{pageId}/brieffor 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 athomepage) and only lists graded, keyworded pages — checkgraded/recommendation_countbefore assuming a row is actionable, and don't confuse a row'sscore(0–100 opportunity/demand ranking) with the page's actual content grade (analysis.overall_score/overall_gradefrom the brief or/analysis). GET /pages/{pageId}— full page detail (embeds latest snapshot + analysis summary + active drafts).- Then, as needed:
GET /pages/{pageId}/content(body as markdown),/analysis(scores incl.estimated_score/estimated_grade+ recommendations, each with acontent_excerpt),/snapshots(history),/content-versions(version history),/drafts(field drafts),/brief(bundles step 4's page summary plus most of this step — fields incl.schemawithvalidation, analysis withestimated_score, recommendations, keywords, queries, outline, links — into one call;fields.body.content_version_iddoubles asbase_version_idforPOST /pages/{pageId}/content, so you don't need a separateGET /contentjust to get it),/report(the brief pluscomparisonvs the previous analysis and aserpsection — what to read after an analyze completes),/links(outbound links),/outline(latest content outline),/queries//performance(live Search Console data). A recommendation'scontent_excerptisnullfor add-type recs (no target nodes) or after a crawl/recrawl regenerated the document's node ids since the analysis ran —POST /pages/{pageId}/analyzeto 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}/pageswith{ path, crawl?: boolean }. Addcrawl: trueto scrape it immediately (responds202+ job instead of201) — the fast lane for a URL MetaMonster hasn't seen yet, e.g. afterGET /pages/lookup404s with asite_id. To add a whole new site first,POST /siteswith{ 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. Aprimary_keyword/secondary_keywordswrite 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}/pageswith{ page_ids: [...], set: {...} }— up to 50 pages of one site per call, same fields minusgsc_url. Don't loop the single PATCH for this. It's partially successful: unknown/foreign ids come back infailed(code: not_found) instead of failing the call, so readmeta.updated/meta.failedrather than the status code. - Body content:
POST /pages/{pageId}/contentwith exactly one ofmarkdownordoc. Passbase_version_id(fromGET …/content) to guard against overwriting concurrent edits — a mismatch returns409. Response:201new version, or200if collapsed into the current one. To roll back to a prior version,GET /pages/{pageId}/content-versions/{versionId}for itsbody_markdownand re-POSTit. - Field drafts:
PUT /pages/{pageId}/drafts/{field}with{ draft_value }(≤20,000 chars).field∈title, meta_description, h1, primary_keyword, body, image_alt, schema.DELETEthe same path to discard. These stage changes — publishing to a CMS is a separate product action, not exposed here. Forfield=schema,draft_valuemust be parseable JSON (else400 invalid_request) — schema.org rule violations are advisory and don't block the save, but come back as avalidationobject alongsidedataso 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 blankdraft_valueclears 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. Withoutforce, an unchanged content fingerprint completes as job statusskipped— no new analysis.409 analysis_in_progressif one's already running for the page. After the job completes,GET /pages/{pageId}/reportis 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. Always202, even when nothing queued — each page either lands indata.jobs[](poll eachjob_idas usual) or indata.skipped[]with the reason the single route would have raised (not_found,no_content,analysis_in_progress,allowance_exhausted,payment_required,error). Branch onmeta.queued, not the status code. Poll the whole batch withGET /jobs?ids=(one request), don't loopGET /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_progressif any crawl is already running for the site. Advances the page's content version (source: "crawl") and marks the current analysiscontent_stale: true— any heldbase_version_idwill 409 afterward, so re-GET /pages/{pageId}/contentafter a recrawl completes. Passnew_pathwhen the slug changed on the live site — always usenew_pathfor 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_resolvedif alreadydismissed/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 itappliedand scores recalculate. All recs auto-resolve content if you omit it:body_contentrecs use the targeted doc nodes plus the full document as context (or the full doc alone);title/meta_description/h1/schemarecs default to the field's active draft, falling back to the latest crawled snapshot value. So the usual pattern for a metadata/schema rec isPUT /pages/{id}/drafts/{field}(write the fix) →resolvewith no body — no need to passcontentunless you want a one-shot check against something you haven't saved. On unavailability the validator auto-passes withskipped: true(availability-first — never blocks on a telemetry check); a skipped verdict does NOT count toward the verified score —recheckto confirm it. Response adds{ addressed, reasoning, skipped, placeholders, validation_scope, validated_node_ids }.400 no_content_to_validateif nothing could be resolved (no draft and no snapshot value either) — the rec stays open and nothing is written;409 already_resolvedif alreadydismissed/applied.POST /recommendations/{recId}/recheck— no body. Re-verify an already-appliedrec after further edits (typically clearing placeholders, or confirming askipped: trueverdict). The only gate isstatus: applied, so a skipped rec is always re-checkable and a real verdict clears the flag; the rec staysappliedeither way — a failing recheck rewritesvalidation_resultonly, and un-promotes the verified score. Response adds the same{ addressed, reasoning, skipped, placeholders, validation_scope, validated_node_ids }block asresolve.409 not_appliedif the rec isn't currentlyapplied. 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 — soresolvewith inlinecontent(which stores nothing) leaves a metadata/schema rec with nothing to recheck:PUT /pages/{id}/drafts/{field}first, or you'll get400 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:
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 itscriteria[].keys join tocriteria_scores[].keyand a rec'srubric_criteria. Free, instant, no allowance.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 insummary.failbefore spending a run — the grader docks the same misses, and paying a model to report them is waste.- Edit:
PATCH /pages/{pageId}(keywords, priority, purpose) ·PUT /pages/{pageId}/drafts/{field}(title/meta/h1/schema) ·POST /pages/{pageId}/content(body). All free. GET /pages/{pageId}/checksagain — confirm the check you aimed at flipped, and thatcontent_changed_since_last_analysisistrue. If it'sfalse, your edit never reached the fingerprint and an analyze withoutforcewill come backskipped— fix that before spending the run rather than forcing past it.POST /pages/{pageId}/analyze— now spend it. Several pages?POST /sites/{siteId}/analyze(≤50, one call).- Poll
GET /jobs/{jobId}(orGET /jobs?ids=for the batch) untilstatusleavesqueued/processing, sleeping the response'sretry_after_seconds— server-controlled, never hardcode it, never hot-loop.retry_after_seconds: nullis your stop signal. - Read the movement, not just the number:
GET /pages/{pageId}/analysis?compare=prevreturns the new analysis plus acomparisonblock (score/component/criterion deltas, and which recs areresolved/still_open/new/dropped).GET /pages/{pageId}/analyses?limit=2gives you the raw pair instead, and a longerlimitthe 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:
GET /pages/{pageId}/analysis— readrecommendations. Each carriestitle,description,target_field, and (forbody_contentrecs)content_excerpt— the exact passage to change, in markdown, no ProseMirror ids needed.- Make the edit. Either:
POST /pages/{pageId}/content(markdown/doc) orPUT /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.
POST /recommendations/{recId}/resolvefor each recommendation you addressed. Fortitle/meta_description/h1/schemarecs, if you fixed the field viaPUT /pages/{pageId}/drafts/{field}in step 2, callresolvewith no body — it validates against that draft automatically (forschema, write valid JSON-LD first; see recommendations.md#the-schema-workflow). Readaddressed/reasoningoff the response:addressed: true,skipped: false,placeholders: []→ the recommendation is nowappliedand its points count toward the verified score. Done.addressed: truewith non-emptyplaceholders→ the structural change landed but template text ("[Your Company]",TODO, etc.) still needs replacing before this counts toward a promoted score.addressed: truewithskipped: true→ nothing actually checked it (you passedforce: true, or the validator was unavailable). It'sappliedand counts towardestimated_score, but not toward the verified score until a real recheck confirms it.addressed: false→ the recommendation is still open;reasoningexplains why the validator didn't buy it. Adjust the edit and resolve again.validation_scopetells you what the verdict was formed against (inline_content/targeted_nodes/full_document/field_draft/field_snapshot, ornullforforce) — useful whenaddressed: falsesurprises you. Body recs always get the full document as context on top ofvalidated_node_ids, so a change made in a neighbouring node still counts.
- If
placeholderswas non-empty, orskippedwastrue: fix the placeholder text (nothing to fix forskipped), thenPOST /recommendations/{recId}/recheck. A clean recheck (placeholders: [],skipped: false,addressed: true) is what promotesoverall_score/score_sourcefromaudittoverified. - 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 askippedjob. - If any edits were made directly on the live site:
POST /pages/{pageId}/recrawlfirst (withnew_pathif a slug changed), poll it tocompleted, thenPOST /pages/{pageId}/analyze { "force": true }.
- If all edits went through
- Poll
GET /jobs/{jobId}untilstatusleavesqueued/processing, waitingretry_after_secondsbetween polls. Analyzing several pages? Trigger withPOST /sites/{siteId}/analyzeand poll them together withGET /jobs?ids=. - Read the new score — either off the completed job's
result(analysis_id,overall_score,overall_grade,score_source), orGET /pages/{pageId}/analysis?compare=prevfor the full picture plus acomparisonblock: score/component/criterion deltas and which recommendations areresolved,still_open,newordroppedsince the previous run. That's the movement in one call — useGET /pages/{pageId}/analyses?limit=2when 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. Checkcontent_changed_since_last_analysisonGET /pages/{pageId}/checksbefore re-triggering:falsemeans 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 withforce: 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-rowrecommendations[]), a flat cross-pagerecommendations[](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_requiredif the org isn't subscribed. Cached ~60s per site (X-Cache: HIT|MISS). Ifview_state != "ready", the pipeline is still running — poll onview_state, notsealed(sealed: trueonly meansgeneration.totalstopped growing, not that the pipeline finished).gsc.avg_positiondoesn't exist; usesummary.scopes.top.now.avg_position/summary.scopes.all.now.avg_position. The homepage never appears inopportunities[]— it's its own row athomepage.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. Itscriteria[].keys join back tocriteria_scoresand a recommendation'srubric_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_analysistells 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,bodyalso carryingcontent_version_idfor use asbase_version_idonPOST /pages/{pageId}/content), latest analysis + components +estimated_score/estimated_grade, open recommendations withcontent_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 byposition. Hard cap 1000 rows — checkmeta.truncated, notmeta.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.404means 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, withpage_idset when a URL matches a tracked page (#fragment/?queryvariants resolve to the same page). Addgroup_by=pageto fold those variants into one row per page (clicks/impressions summed,ctrrecomputed,positionimpressions-weighted,urlthe page's canonical URL) — folding runs before the cap. Rows are capped at 1000 (top by clicks) — checkmeta.truncated;totalsstill 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 tourl). If a page's numbers look far below whatGET /sites/{siteId}/performanceattributes to itspage_id, the storedgsc_urlis pointing at a variant — repoint or clear it withPATCH /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 triggeringanalyze/recrawlall 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 passbase_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=…returns404on 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 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 — 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} (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 #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).
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.
statuslifecycle:pending(open) →in_progress(change underway) →applied(change made, points earned) ordismissed(rejected, seedismissal_rationale).point_value(int|null) — additive points this rec is worth;nullpredates the additive model.carried_from_recommendation_id(int|null) — set on display-only history rows carried forward from a prior analysis (statusapplied); 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},nulluntil verified.addressed: falsemeans the applied change did NOT resolve the recommendation (no points earned).skipped: truemeans no validator verdict backs the pass (force-resolved, or the validator was unavailable) — it does NOT count toward the verified score;recheckto confirm.placeholderslists template fragments (e.g."[Your Company]") to replace before publishing.validation_scopenames the content the verdict was formed against (inline_content/targeted_nodes/full_document/field_draft/field_snapshot), ornullwhen no scope was recorded — force-resolved, verified in-app outside this API, or written before scopes existed;validated_node_idsis set only fortargeted_nodes.content_excerpt(string|null) — markdown of the doc node(s) this rec targets, for locating the passage without ProseMirror ids. Populated forbody_content-target recs when resolvable;nullfor 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 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, 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_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 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}.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).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.
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"