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), 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 /healthreturns{ "ok": true };GET /mereturns{ "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),recommendations_in_progress(409, checks recommendations are still being written; read them shortly instead of regenerating),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, 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) andno_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, 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. The default bucket is ~120 req/60s; a separate ~600 req/60s bucket covers job polling (
GET /jobs/{jobId}andGET /jobs?ids=) so polling never eats your general budget; and a small ~6 req/60sllmbucket 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}/generateandPOST /pages/{pageId}/checks/recommendations. 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 /sitesfinds the site (it 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 (a 404 tells you whether toPOST /sitesorPOST /sites/{siteId}/pagesif it doesn't exist yet).GET /sites/{siteId}/pagesfinds pages, with rich filters:search,is_priority,has_keyword,has_draft,has_recommendations,grade(ormin_factors_passing_pctfor organizations on the new analysis),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(a 0-100 opportunity/demand ranking) with the page's actual content grade (analysis.overall_score/overall_gradefrom the brief or/analysis). GET /pages/{pageId}returns full page detail (it embeds the latest snapshot, an analysis summary, and active drafts).- Then, as needed:
GET /pages/{pageId}/content(body as markdown),/analysis(scores incl.estimated_score/estimated_gradeplus 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 into one call: fields incl.schemawithvalidation, analysis withestimated_score, recommendations, keywords, queries, outline, and links;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}/analyzerefreshes 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 (the call responds202+ job instead of201). This is 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:201for a new version, or200if it 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. 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. Withoutforce, an unchanged content fingerprint completes as job statusskippedwith 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 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_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.
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: anangleplus proposed sections.400 primary_keyword_requiredwithout a keyword;402 payment_requiredpast the plan allowance (on the free preview, only the homepage and one brief). A brief already generating is not an error: the202returns that run's job. Poll the job, thenGET /pages/{pageId}/outline. Review it withPATCH /pages/{pageId}/outline/sections(see the reference); accepting inserts the headings into the body and returns acontent_version_id. Write the section text next withPOST /pages/{pageId}/content, passing that id asbase_version_id. - AI fix for one recommendation.
POST /recommendations/{recId}/fix(orPOST /pages/{pageId}/actionswith{recommendation_id}, or{target_field, prompt}for a fix without a rec). Poll thejob_ac_job, thenGET /actions/{actionId}for the before/after, thenPOST /actions/{actionId}/accept,/reject, or/revisewith{instructions}. Accepting saves a metadata fix as the field's draft and a body fix as a new content version, and marks the recapplied. 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-rewriteswith{recommendation_ids, instructions?}. No accept step: when thejob_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 newDraft. Costs credits like the app. - Alt text.
GET /pages/{pageId}/images, thenPUT /pages/{pageId}/images/alt({src|image_id, alt}, no AI) orPOST /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_resolvedif alreadydismissed/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 recappliedand 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) thenresolvewith no body. There's 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; it never blocks on a telemetry check). A skipped verdict does NOT count toward the verified score;recheckto confirm it. The 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-verifies 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. The 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.
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,componentsorpoint_value. Report factors:36 of 41 factors passingfromfactors_passing/factors_total(pass/pass + warn + fail;not_applicableexcluded; bothnullwhen no factor applies, which means "no factors apply", not 0/0). GET /pages/{id}/analysisreturns the factors, checks and recommendations of the latest checks analysis;compareis ignored andGET /pages/{id}/analyseslists factor history (includeis ignored). A page with only a legacy analysis is404 No analysis found: analyze it.GET/PATCH /pages/{id},/briefand/reportembedanalysis: { id, analyzed_at, factors_passing, factors_total, recommendations_status }(null when the page has none)./reporthascomparison: null.POST .../analyzereturnsjob_ck_job ids; poll them the same way. The bulk trigger can skip a page withengine_paused.recommendations_statuscan benot_requested: the findings are in but no recommendations were written. CallPOST /pages/{id}/checks/recommendations(MCP:write_recommendations), then readGET /pages/{id}/analysis(or/brief) once the status isready.generatingmeans wait;failedmeans a templated fallback list shipped.- Find work with
GET /sites/{id}/pages?min_factors_passing_pct=80(agradefilter is a 400) andGET /sites/{id}/opportunities, whoseanalysisblock and per-rowanalysisreplacegrade. A row'sscoreis the demand / opportunity rank, not an analysis result. GET /sites/{id}/rubricreturns the factor list. The/checks/*endpoints stay as aliases.- MCP: use
analyze_page,get_page_report,write_recommendations,get_rubricandfind_pages(min_factors_passing_pct); thechecks_*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:
GET /sites/{siteId}/rubricshows 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}/checksshows 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. It's 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 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:
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. Animage_altrec is fixed per image:PUT /pages/{pageId}/images/altorPOST /pages/{pageId}/images/generate-alt, then resolve the rec.- 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), which is 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 completed checks analysis may report
recommendations_status: generating; pollGET /pages/{pageId}/checks/analysisuntilreadyorfailed; itsrecommendationsfield then carries the list (it is[]whilegenerating). POST /pages/{pageId}/checks/recommendations(rewrite a checks analysis's recommendations) is asynchronous: it returns202 {data: {analysis_id, page_id, recommendations_status: "generating"}}at once; pollGET /pages/{pageId}/checks/analysisas 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. 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: the ranked "what should I work on" report, withopportunities[](scored pages, with per-rowrecommendations[]), a flat cross-pagerecommendations[](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_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. It bundles the page summary, resolved field values (title/meta/h1/body markdown,bodyalso carryingcontent_version_idfor use asbase_version_idonPOST /pages/{pageId}/content), the latest analysis with components andestimated_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. Generate one withPOST /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, 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,DELETE /pages, the recommendation verbs, AI generation, and triggeringanalyze/recrawlall 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 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. For research that does fetch live (seed metrics plus related ideas), useGET /keywords/research?keyword=...(optionallysite_idfor 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; 429keyword_research_cap_reachedwithdetails.resets_at) and 30 per minute per key;meta.daily_lookups.remainingshows 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.
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 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"