Pages

Pages

A page is an individual URL discovered on a site. Pages are long-lived — they persist across crawls, accumulating snapshots, analysis, and drafts over time.

Read endpoints require sites:read; write endpoints require content:write.

For a single-call work packet see brief.


GET /sites/{siteId}/pages

List a site's pages (lean summary shape), with filtering, sorting, and pagination. Priority pages always sort first, then by your chosen sort/direction.

Scope: sites:read

Path parameters

Parameter Type Description
siteId integer The site's ID

Query parameters

Parameter Type Default Notes
search string Case-insensitive match against url or path (max 200 chars)
is_priority true|false Only priority / non-priority pages
has_keyword true|false Pages that have / don't have a primary_keyword
has_draft true|false Pages that have / don't have an active draft
has_recommendations true|false Pages that have / don't have pending recommendations
grade string Match the latest analysis's overall_grade (e.g. A, B)
min_score number opportunity_score >= this value
max_score number opportunity_score <= this value
sort enum path One of created_at, updated_at, url, path, is_priority, primary_keyword, opportunity_score
direction asc|desc asc Sort direction
page integer 1 1-indexed. Minimum 1.
limit integer 50 Minimum 1, maximum 100.

Regardless of sort, priority pages (is_priority: true) are always listed before non-priority pages.

Request

curl "https://new.metamonster.ai/api/v1/sites/42/pages?is_priority=true&min_score=50&sort=opportunity_score&direction=desc&limit=20" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": [
    {
      "id": 5001,
      "url": "https://example.com/pricing",
      "path": "/pricing",
      "site_id": 42,
      "title": "Pricing — Example",
      "primary_keyword": "example pricing",
      "is_priority": true,
      "opportunity_score": 78
    }
  ],
  "meta": { "total": 128, "page": 1, "per_page": 20 }
}

Each item (page summary shape):

Field Type Description
id integer Page ID
url string Full URL
path string | null URL path
site_id integer | null Owning site
title string | null Title from the page's latest snapshot
primary_keyword string | null Target keyword
is_priority boolean Whether the page is marked priority
opportunity_score number | null MetaMonster opportunity score

Errors

Status When
404 not_found No site with that ID in your organization (Site not found)

POST /sites/{siteId}/pages

Create a page by path on one of your sites. Pass crawl: true to immediately scrape the live page instead of getting back the bare record — this is the fast lane for "analyze this URL" when the URL isn't a page MetaMonster already knows about: create it here with crawl: true, then poll the returned job, then run POST /pages/{pageId}/analyze.

Scope: content:write

Path parameters

Parameter Type Description
siteId integer The site's ID

Request body

Field Type Notes
path string Required. 1–500 chars. Normalized to lowercase with a leading / and (unless it looks like a file, e.g. ends .html) a trailing /.
crawl boolean Default false. If true, immediately scrape the live page — see below.
title string Optional, max 300 chars.
purpose string Optional, max 2000 chars.
primary_keyword string Optional, max 200 chars.
secondary_keywords string[] Optional, up to 20 entries, each max 200 chars.
curl -X POST https://new.metamonster.ai/api/v1/sites/42/pages \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "path": "/pricing", "crawl": true }'

Response 201 (no crawl)

{
  "data": {
    "id": 5001,
    "url": "https://example.com/pricing/",
    "path": "/pricing/",
    "site_id": 42,
    "title": null,
    "primary_keyword": null,
    "is_priority": true,
    "opportunity_score": null
  }
}

Same page summary shape as GET /sites/{siteId}/pages above. title is null until an analysis produces a snapshot.

Response 202 (crawl: true)

{ "data": { "job_id": "job_cr_990", "status": "processing", "page_id": 5001, "retry_after_seconds": 5 } }
Field Type Description
job_id string Poll with GET /jobs/{jobId}
status string (processing) Always processing on the trigger response
page_id integer The newly created page
retry_after_seconds integer Seconds to wait before the first poll (and between polls)

The page row is created before the crawl is queued either way, so a 202 response's page_id already refers to a real page — use it right away (e.g. for PATCH /pages/{pageId}) without waiting on the job.

Not blocked by a running site crawl. Unlike POST /pages/{pageId}/recrawl, this endpoint's crawl: true path does not check for (or 409 on) an in-progress site-level crawl. A brand-new site's setup crawl can be running at the same time you create-and-crawl an individual page here — both proceed independently. The only gate on the crawl path is an active subscription.

Errors

Status When
400 invalid_request path missing or fails validation
402 payment_required crawl: true without an active subscription (checked before any write — no page is created)
404 not_found No site with that ID in your organization (Site not found)
409 conflict A live (non-deleted) page already exists at that path — details.page_id carries the existing page's id

GET /pages/lookup

Resolve an absolute URL to one of your pages — the entry point for "analyze this URL" flows, when you have a URL but not a page_id. Host matching ignores www.; path matching ignores trailing slashes. If several pages match, the most recently added site wins (the same domain can be added more than once), then its most recently updated live page.

Scope: sites:read

Query parameters

Parameter Type Notes
url string Required. An absolute http(s) URL, max 2048 chars.

Request

curl "https://new.metamonster.ai/api/v1/pages/lookup?url=https://example.com/pricing" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": {
    "page_id": 5001,
    "site_id": 42,
    "url": "https://example.com/pricing",
    "path": "/pricing"
  }
}

Errors

Status When
400 invalid_request url is missing or isn't an absolute http(s) URL
404 site_not_found No site in your organization matches the URL's host. details: { domain }. Add the site with POST /sites.
404 page_not_found A site matches the host, but no live page matches the path. details: { site_id, domain, path }. Create the page with POST /sites/{siteId}/pages.

GET /pages/{pageId}

Fetch a single page with full detail — including its latest snapshot, a summary of its latest analysis, and its active field drafts.

Scope: sites:read

Path parameters

Parameter Type Description
pageId integer The page's ID

Request

curl https://new.metamonster.ai/api/v1/pages/5001 \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": {
    "id": 5001,
    "url": "https://example.com/pricing",
    "path": "/pricing",
    "name": null,
    "purpose": "Convert plan comparisons",
    "planned": false,
    "site_id": 42,
    "primary_keyword": "example pricing",
    "secondary_keywords": ["pricing plans", "cost"],
    "is_priority": true,
    "is_priority_manual": true,
    "opportunity_score": 78,
    "gsc_url": "https://example.com/pricing",
    "gsc_indexed": true,
    "discovered_via": "crawl",
    "created_at": "2026-05-01T13:00:00Z",
    "updated_at": "2026-07-20T09:30:00Z",
    "last_analyzed_at": "2026-07-19T18:00:00Z",
    "latest_snapshot": {
      "id": 8100,
      "title": "Pricing — Example",
      "meta_description": "See Example's pricing plans.",
      "h1": "Pricing",
      "word_count": 640,
      "status_code": 200,
      "created_at": "2026-07-20T09:20:00Z"
    },
    "latest_analysis": {
      "id": 7200,
      "overall_score": 78,
      "overall_grade": "B",
      "status": "completed",
      "completed_at": "2026-07-19T18:00:00Z",
      "score_source": "audit",
      "content_stale": false
    },
    "drafts": [
      { "field": "title", "status": "draft", "draft_value": "Example Pricing: Plans & Costs" }
    ]
  }
}

Page detail shape:

Field Type Description
id integer Page ID
url string Full URL
path string | null URL path
name string | null Display name (planned-page workflows). Read-only.
purpose string | null Page context — what this page is for. Feeds keyword generation and outline prompts. Writable via PATCH.
planned boolean true for pages planned before the site is live (no crawled snapshot yet)
site_id integer | null Owning site
primary_keyword string | null Target keyword
secondary_keywords string[] | null Secondary keywords
is_priority boolean Whether the page is marked priority
is_priority_manual boolean | null Whether priority was set manually (vs. computed)
opportunity_score number | null Opportunity score
gsc_url string | null Matched Google Search Console URL — the exact URL per-page GSC reads are filtered on. Writable via PATCH.
gsc_indexed boolean | null Whether GSC reports the page as indexed
discovered_via string | null How the page was discovered (e.g. crawl)
created_at string | null When the page was first seen
updated_at string | null Last update
last_analyzed_at string | null Completion time of the latest analysis, or null
latest_snapshot object | null Lean snapshot (see Snapshots), or null
latest_analysis object | null Analysis summary (below), or null
drafts array Active field drafts — { field, status, draft_value } (see Drafts)

latest_analysis summary object: { id, overall_score, overall_grade, status, completed_at, score_source, content_stale }. score_source is audit or verified (see the analysis field table below). content_stale: true means the page's content changed after this analysis ran — treat overall_score/overall_grade as void until a re-analysis completes. For the full analysis with components and recommendations, use GET /pages/{pageId}/analysis.

Errors

Status When
404 not_found No such page in your organization, or the page is deleted (Page not found)

PATCH /pages/{pageId}

Update a page's target keywords, priority flag, page context, and Search Console URL. This is a plain data update — no AI, no credits consumed.

If a keyword-generation workflow is currently running for this page's site (primary_keyword, status pending/processing), writes that touch primary_keyword or secondary_keywords are rejected with 409 keyword_generation_in_progress — the worker owns primary_keyword while it runs and would silently overwrite an API write. Retry once the workflow finishes. Writes to is_priority/purpose alone are never gated. A successful keyword write also recomputes the site's cached opportunity scores and priority set (best-effort — a recompute failure never fails the request). Because the response is built from the page row as of the update (before that recompute runs), opportunity_score/is_priority in this response can predate the recompute's effects — fetch the page again with GET /pages/{pageId} to see the recomputed values.

Scope: content:write

Path parameters

Parameter Type Description
pageId integer The page's ID

Request body

Send at least one of these fields. Unknown fields are rejected.

Field Type Notes
primary_keyword string | null 1–200 chars. null clears it.
secondary_keywords string[] | null Up to 100 items, each 1–200 chars. null clears it.
is_priority boolean Marks the page priority. Also records this as a manual override (is_priority_manual).
purpose string | null Max 2000 chars. Trimmed; an empty/whitespace-only string clears it (null). Feeds keyword generation and outline prompts.
gsc_url string | null Max 2048 chars. The exact URL sent to Search Console with an equals page filter for this page's GSC reads. Must be an absolute http(s) URL on the site's canonical host and must not contain a #fragment. null clears it, so the next reconciliation re-derives it from live GSC data (matching falls back to the page's url in the meantime). gsc_indexed is left untouched.
curl -X PATCH https://new.metamonster.ai/api/v1/pages/5001 \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "primary_keyword": "example pricing", "is_priority": true }'

Self-healing a wrong gsc_url. Per-page GSC endpoints (/queries, /performance) measure exactly one URL. If a page's Search Console numbers look far smaller than what GET /sites/{siteId}/performance attributes to its page_id, the stored gsc_url is pointing at a variant — repoint or clear it:

curl -X PATCH https://new.metamonster.ai/api/v1/pages/5001 \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "gsc_url": null }'

Response 200

Returns the updated page in the same detail shape as GET /pages/{pageId} — embeds included, reflecting the post-update state. One canonical page representation everywhere:

{
  "data": {
    "id": 5001,
    "url": "https://example.com/pricing",
    "path": "/pricing",
    "name": null,
    "purpose": "Convert plan comparisons",
    "planned": false,
    "site_id": 42,
    "primary_keyword": "example pricing",
    "secondary_keywords": ["pricing plans"],
    "is_priority": true,
    "is_priority_manual": true,
    "opportunity_score": 78,
    "gsc_url": "https://example.com/pricing",
    "gsc_indexed": true,
    "discovered_via": "crawl",
    "created_at": "2026-05-01T13:00:00Z",
    "updated_at": "2026-08-01T10:00:00Z",
    "last_analyzed_at": "2026-07-30T09:00:00Z",
    "latest_snapshot": { "id": 8100, "title": "Pricing — Example", "meta_description": "…", "h1": "Pricing", "word_count": 640, "status_code": 200, "created_at": "2026-07-29T08:00:00Z" },
    "latest_analysis": { "id": 7200, "overall_score": 72, "overall_grade": "B-", "status": "completed", "completed_at": "2026-07-30T09:00:00Z", "score_source": "audit", "content_stale": false },
    "drafts": [{ "field": "title", "status": "draft", "draft_value": "Example Pricing: Plans & Costs" }]
  }
}

Errors

Status When
400 invalid_request No recognized field provided (No fields to update), or a field fails validation, or an unknown field was sent. For gsc_url this includes a non-absolute URL, a URL carrying a #fragment, and a host that isn't the site's own (gsc_url: host must match the site's domain (example.com))
404 not_found No such page in your organization (Page not found)
409 keyword_generation_in_progress The write touches primary_keyword/secondary_keywords while a keyword-generation workflow is running for this site. Retry after it completes.

PATCH /sites/{siteId}/pages

Apply the same patch to up to 50 pages of one site in a single call — the bulk form of PATCH /pages/{pageId}. Use it to mark a batch of pages priority, assign a shared purpose, or clear keywords across a set. Plain data update: no AI, no credits.

Scope: content:write

This endpoint is partially successful by design: an ID that isn't a live page of this site is reported in failed rather than failing the whole call, and one failed write doesn't abandon the rest. Pages are written one at a time, in the order you list them.

gsc_url is not settable here — it's per-page by definition (its host is validated against the site's domain). Use PATCH /pages/{pageId} for it.

Path parameters

Parameter Type Description
siteId integer The site's ID

Request body

Field Type Notes
page_ids integer[] Required. 1–50 unique page IDs, each ≥ 1. Every ID must be a live (non-deleted) page of this site.
set object Required. The patch applied to every listed page — at least one field. Same fields and validation as PATCH /pages/{pageId} minus gsc_url: primary_keyword, secondary_keywords, is_priority, purpose. Unknown fields are rejected.
curl -X PATCH https://new.metamonster.ai/api/v1/sites/42/pages \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "page_ids": [5001, 5002, 5003], "set": { "is_priority": true } }'

The keyword gate applies to the batch as a whole: if set touches primary_keyword/secondary_keywords while a keyword-generation workflow is running for this site, the entire call is rejected with 409 keyword_generation_in_progress and no page is written. A successful keyword batch recomputes the site's opportunity scores and priority set once, after the batch (best-effort), so — as with the single PATCH — the opportunity_score/is_priority values in this response can predate the recompute.

Response 200

{
  "data": {
    "updated": [
      {
        "id": 5001,
        "url": "https://example.com/pricing",
        "path": "/pricing",
        "site_id": 42,
        "is_priority": true,
        "is_priority_manual": true,
        "latest_snapshot": { "id": 8100, "title": "Pricing — Example" },
        "latest_analysis": { "id": 7200, "overall_score": 72, "overall_grade": "B-" },
        "drafts": []
      }
    ],
    "failed": [
      { "page_id": 9999, "code": "not_found", "message": "Page not found on this site" }
    ]
  },
  "meta": { "requested": 2, "updated": 1, "failed": 1 }
}
Field Type Description
data.updated object[] Each successfully updated page, in the same detail shape as GET /pages/{pageId} (embeds included), in request order
data.failed object[] One entry per page that wasn't updated, in request order
data.failed[].page_id integer The ID you sent
data.failed[].code not_found | error not_found = not a live page of this site (wrong site, another organization, deleted, or nonexistent — indistinguishable on purpose). error = the write itself failed.
data.failed[].message string Human-readable reason. For code: error it is generic (Failed to update page) for server-side failures — the underlying cause is logged, not returned — while a client-caused (4xx) reason is passed through.
meta.requested integer How many IDs you sent
meta.updated integer data.updated.length
meta.failed integer data.failed.length

meta.updated + meta.failed always equals meta.requested. A call where every ID failed is still a 200 — check meta, not the status code.

Errors

Status When
400 invalid_request page_ids empty, longer than 50, containing duplicates or non-positive/non-integer IDs; set empty or carrying an unknown field (including gsc_url); or a field fails validation
404 not_found No site with that ID in your organization (Site not found)
409 keyword_generation_in_progress set touches primary_keyword/secondary_keywords while a keyword-generation workflow is running for this site. Nothing was written — retry after it completes.

GET /pages/{pageId}/content

Get the page's current body content, serialized to markdown, along with the ID of the content version it came from.

Scope: sites:read

Request

curl https://new.metamonster.ai/api/v1/pages/5001/content \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": {
    "content_version_id": 9300,
    "source": "outline",
    "version_created_at": "2026-08-01T10:00:00Z",
    "markdown": "# Pricing\n\nSee our plans below…"
  }
}
Field Type Description
content_version_id integer | null The content version this markdown came from. null if the content fell back to the latest snapshot (or there's no content).
source string | null Which write path produced the served version — manual_edit (editor or API save), crawl (recrawl overwrote it), outline (outline sections accepted), action (an AI action applied a change). null when serving the crawl snapshot fallback. Other source values may appear over time. If your cached base_version_id starts 409ing, re-GET and check this.
version_created_at string | null When the served content version was created. null on snapshot fallback.
markdown string The body content as markdown. Empty string "" if the page has no content.

Errors

Status When
404 not_found No such page in your organization (Page not found)

POST /pages/{pageId}/content

Save a new body content version for the page, from either markdown or a raw ProseMirror document. Stores exactly what you send — no AI generation.

Scope: content:write

Request body

Provide exactly one of markdown or doc:

Field Type Notes
markdown string Markdown source. Max 1,000,000 characters. Parsed into MetaMonster's document format.
doc object A ProseMirror document (MetaMonster's internal content format). Max 2,000,000 bytes when serialized. Validated against the schema.
base_version_id integer | null Optional. If provided, the save only succeeds when the page's current content version matches it — an optimistic-concurrency guard.
curl -X POST https://new.metamonster.ai/api/v1/pages/5001/content \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "markdown": "# Pricing\n\nOur updated plans…", "base_version_id": 9300 }'

Response

Status Meaning Body
201 Created A new content version was created { "data": { "content_version_id": 9301, "collapsed": false } }
200 OK The edit was merged into the existing latest version (consecutive manual edits collapse into one version) { "data": { "content_version_id": 9300, "collapsed": true } }
Field Type Description
content_version_id integer The resulting content version's ID
collapsed boolean true if merged into the existing version, false if a new version was created

Errors

Status When
400 invalid_request Neither markdown nor doc provided; both provided (Provide either markdown or doc, not both); markdown/doc exceeds its size limit; markdown couldn't be parsed; or doc isn't a valid ProseMirror document
404 not_found No such page in your organization (Page not found)
409 conflict base_version_id was provided but doesn't match the page's current content version (Content was updated elsewhere)

See also Content versions for reading version history and rolling back to an earlier version.


POST /pages/{pageId}/recrawl

Tell MetaMonster to re-fetch the page from the live site — this is how MetaMonster learns about edits you made directly on the site (not through this API). Fully async: guards and any slug migration run synchronously in this request, but the actual scrape happens in a worker. Returns 202 with a job id to poll.

Scope: content:write

Path parameters

Parameter Type Description
pageId integer The page's ID

Request body

Field Type Notes
new_path string Optional. Must start with /, max 500 chars. Migrates the page's slug before recrawling — see Migrating a slug below.
curl -X POST https://new.metamonster.ai/api/v1/pages/5001/recrawl \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Response 202

{ "data": { "job_id": "job_cr_990", "status": "processing", "page_id": 5001, "retry_after_seconds": 5 } }
Field Type Description
job_id string Poll with GET /jobs/{jobId}
status string (processing) Always processing on the trigger response — the crawl row is created in this same request
page_id integer The page being recrawled
retry_after_seconds integer Seconds to wait before the first poll (and between polls) — the same hint the job resource reports. Server-controlled; read it rather than hardcoding an interval.

A recrawl requires an active subscription — unlike analyze, there's no free-preview carve-out. Only one crawl (single-page recrawl or full-site crawl) may be in flight per site at a time; triggering a second one 409s.

On completion, the recrawl advances the page's content version (source: "crawl") and marks the page's current analysis content_stale: true. Any base_version_id you were holding for POST /pages/{pageId}/content is now stale and will 409 — re-GET /pages/{pageId}/content for the fresh content_version_id before your next save. If the live page returned an error response, no snapshot is written and the completed job's result.snapshot_id is null — see Jobs.

Migrating a slug with new_path

If you renamed the URL directly on the site, pass new_path so MetaMonster tracks it as the same page (same id) rather than losing history. The page row's url/path are updated in place before the crawl is queued; id never changes.

curl -X POST https://new.metamonster.ai/api/v1/pages/5001/recrawl \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "new_path": "/pricing-plans" }'

Full-site-crawl hazard. This new_path migration only happens when you explicitly call this endpoint. If a slug rename is instead picked up by a full-site crawl (the site-level crawl your dashboard runs periodically, not this endpoint) without ever having gone through new_path here, the crawler has no way to know the new URL is "the same page" — it creates a brand-new page record at the new path, and the old page record becomes orphaned (still exists, no longer reachable from the live site). If you're renaming slugs via direct site edits and want to preserve page history/analysis/drafts, always migrate through new_path on this endpoint rather than letting a routine crawl discover the rename on its own.

Errors

Status When
400 no_content The page has no url to recrawl (Page has no URL to recrawl)
400 invalid_request new_path doesn't resolve to a valid URL (Invalid path)
401 unauthorized Missing/invalid key
402 payment_required No active subscription (An active subscription is required to recrawl pages.)
403 forbidden Key lacks content:write
404 not_found No such page in your organization (Page not found)
409 crawl_in_progress A crawl (this recrawl or a full-site crawl) is already running for the site (A crawl is already in progress for this site.)
409 path_conflict new_path collides with another live page's URL on the same site (Another page already uses that path.)

GET /pages/{pageId}/analysis

Get the page's latest SEO analysis, including its per-criterion components and its recommendations. Returns the most recent analysis that isn't skipped.

Scope: sites:read

Query parameters

Parameter Type Default Description
compare string prev or an analysis ID. Attaches a comparison block showing what moved since that run — see Comparing against a previous analysis. Omit it and no comparison key is returned at all.

Request

curl https://new.metamonster.ai/api/v1/pages/5001/analysis \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": {
    "id": 7200,
    "page_id": 5001,
    "site_id": 42,
    "snapshot_id": 8100,
    "status": "completed",
    "overall_score": 78,
    "overall_grade": "B",
    "estimated_score": 90,
    "estimated_grade": "A",
    "analyzed_content": {
      "hash": "9f2c…",
      "fields": {
        "title": { "value": "Pricing — Example", "hash": "1a3b…" },
        "meta_description": { "value": "…", "hash": "4c5d…" },
        "schema": { "value": null, "hash": "6e7f…" },
        "body_content": { "value": "# Pricing…", "hash": "8a9b…" },
        "primary_keyword": { "value": "pricing", "hash": "0c1d…" },
        "secondary_keywords": { "value": "plans\ntiers", "hash": "2e3f…" }
      },
      "sources": {
        "title": { "source": "draft", "draft_id": 4410 },
        "meta_description": { "source": "snapshot" },
        "schema": { "source": "none" },
        "body_content": { "source": "content_version", "content_version_id": 9100 }
      }
    },
    "score_source": "audit",
    "verified_at": null,
    "content_stale": false,
    "curve_shape": "proportional",
    "triggered_by": "auto_run",
    "started_at": "2026-07-19T17:55:00Z",
    "completed_at": "2026-07-19T18:00:00Z",
    "created_at": "2026-07-19T17:55:00Z",
    "updated_at": "2026-07-19T18:00:00Z",
    "components": [
      {
        "id": 6100,
        "analysis_id": 7200,
        "component_type": "title",
        "label": "Title tag",
        "status": "completed",
        "score": 80,
        "grade": "B",
        "estimated_score": 95,
        "estimated_grade": "A",
        "criteria_scores": { "…": "…" },
        "rationale": "The title is clear but could include the primary keyword earlier.",
        "model": "claude-…",
        "created_at": "2026-07-19T18:00:00Z",
        "updated_at": "2026-07-19T18:00:00Z"
      }
    ],
    "recommendations": [
      {
        "id": 5500,
        "analysis_id": 7200,
        "page_id": 5001,
        "title": "Lead the title with the primary keyword",
        "description": "Move \"pricing\" toward the front of the title tag.",
        "why_it_matters": "Front-loaded keywords improve relevance signals.",
        "impact": "medium",
        "status": "pending",
        "target_field": "title",
        "operation": "replace",
        "sort_order": 1,
        "created_at": "2026-07-19T18:00:00Z",
        "updated_at": "2026-07-19T18:00:00Z"
      }
    ]
  }
}

The top-level object is the analysis. Key fields:

Field Type Description
id integer Analysis ID
page_id / site_id integer Owning page / site
snapshot_id integer | null The snapshot that was analyzed
status string Analysis status (e.g. completed)
overall_score number | null Current overall score
overall_grade string | null Current overall letter grade
estimated_score / estimated_grade number / string | null Projected score if every open recommendation is applied. Additive model: overall_score plus the sum of open recommendations' point_value.
analyzed_content object | null JSON object of the content the analysis ran against (not a string): hash, fields and sources. null if not yet populated. See Analyzed content provenance.
score_source string (audit | verified) Provenance of overall_score. audit = produced by the analysis run. verified = updated in place by verify-on-apply after recommendations were applied — see verified_at for when.
verified_at string | null Timestamp of the verify-on-apply update, if any
content_stale boolean True when the page's content changed after this analysis ran. Treat overall_score/overall_grade as void until a re-analysis completes.
curve_shape string (flat | proportional) Grade-curve formula this analysis was scored under. Scores are not comparable across different curve shapes (recalibrated 2026-07-23).
triggered_by string | null What triggered the analysis
started_at / completed_at string | null Timing
created_at / updated_at string | null Row timestamps
components array Per-criterion breakdown (ordered by component_type); [] if none
recommendations array Actionable recommendations (ordered by sort_order); [] if none

Each component carries: id, analysis_id, component_type, label, status, score, grade, estimated_score, estimated_grade, criteria_scores, rationale, model, site_id, created_at, updated_at.

Each recommendation carries: id, analysis_id, page_id, site_id, title, description, why_it_matters, impact, status, source, source_components, operation, target_field, target_node_ids, after_node_id, insertion_point, rubric_criteria, resolution_method, resolved_at, dismissal_rationale, sort_order, point_value, carried_from_recommendation_id, verification, content_excerpt, created_at, updated_at.

status lifecycle: pending (open, not yet acted on) → in_progress (an AI action or edit is underway) → applied (change made, points earned) or dismissed (rejected — see dismissal_rationale).

  • point_value (integer | null) — additive score points this recommendation is worth. estimated_score = overall_score + the sum of open recommendations' point_value. null on recommendations predating the additive model.
  • carried_from_recommendation_id (integer | null) — set on display-only history rows carried forward (status applied) from a prior analysis. Exclude these when counting or summing this analysis's work.
  • verification (object | null) — AI verification verdict for an applied recommendation; null until verified. Shape: { addressed: boolean, reasoning: string, placeholders: string[], skipped: boolean, validation_scope: string|null, validated_node_ids: string[]|null }. addressed: false means the applied change did NOT resolve the recommendation and it earned no points. skipped: true means no validator verdict backs the pass (force-resolved, or the validator was unavailable) — it does NOT count toward the verified score; recheck to confirm it. placeholders lists template fragments (e.g. "[Your Company]") a human must replace before publishing. validation_scope/validated_node_ids record which content the verdict was formed against; validation_scope is null when no scope was recorded (force-resolved, verified in-app outside this API, or written before scopes existed) — see recommendations.md.
  • content_excerpt (string | null) — markdown of the doc node(s) this recommendation targets, so you can locate the passage without ProseMirror node ids. Populated for body_content-target recommendations when the targeted nodes could be resolved from the current content version; null for metadata-target recommendations or when nothing could be resolved.

Act on individual recommendations with Recommendations: dismiss, resolve (with AI verification), and recheck.

Analyzed content provenance

analyzed_content.sources tells you exactly which content the score was formed against — the answer to "did my pending draft count?".

Field Shape
title / meta_description / schema { "source": "draft" | "snapshot" | "none", "draft_id": integer }
body_content { "source": "content_version" | "none", "content_version_id": integer }
  • draft — an active draft (status draft, pending or generating) supplied the value; draft_id identifies it. A draft with a blank or null value is skipped in favour of the snapshot.
  • snapshot — the value came from the last crawl of the live page.
  • none — neither a draft nor the snapshot had a value, so nothing was scored for that field.
  • content_version — the body markdown was rendered from that content version; content_version_id is the version that was scored.

sources is absent on analyses that ran before provenance was recorded (it is descriptive only and is not part of hash, so it never affects change detection). Treat a missing sources as "unknown", not as "nothing was drafted".

Comparing against a previous analysis

?compare= returns the same analysis with a comparison block attached: what the score, each component, each criterion and each recommendation did between that earlier run and this one. It's the "did my edits work?" call — one request instead of fetching two analyses and diffing them yourself.

Value Meaning
prev The newest completed analysis of this page that ran before the one being returned
{analysisId} A specific completed analysis of this page — take an id from GET /pages/{pageId}/analyses
curl "https://new.metamonster.ai/api/v1/pages/5001/analysis?compare=prev" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"
{
  "data": {
    "id": 7201,
    "overall_score": 84,
    "overall_grade": "B",
    "components": ["…"],
    "recommendations": ["…"],
    "comparison": {
      "previous_analysis_id": 7200,
      "previous_analyzed_at": "2026-07-19T18:00:00Z",
      "overall_score": { "prev": 72, "now": 84, "delta": 12 },
      "overall_grade": { "prev": "C", "now": "B" },
      "components": [
        { "component_type": "metadata", "score": { "prev": 70, "now": 90, "delta": 20 } },
        { "component_type": "content", "score": { "prev": 74, "now": 80, "delta": 6 } }
      ],
      "criteria": [
        { "component_type": "metadata", "key": "title_keyword", "label": "Keyword in title", "prev": 1, "now": 4, "delta": 3 },
        { "component_type": "metadata", "key": "meta_length", "label": "Meta description length", "prev": null, "now": 3, "delta": null }
      ],
      "recommendations": {
        "new": [{ "id": 5610, "title": "Add an FAQ block", "…": "…" }],
        "still_open": [{ "id": 5611, "title": "Tighten the meta description", "…": "…" }],
        "resolved": [{ "id": 5612, "title": "Lead the title with the primary keyword", "…": "…" }],
        "dropped": [{ "id": 5502, "title": "Add an internal link to /pricing", "…": "…" }]
      },
      "matching": "best_effort_title"
    }
  }
}
Field Type Description
previous_analysis_id integer The analysis being compared against
previous_analyzed_at string | null Its completed_at (falling back to created_at)
overall_score object { prev, now, delta }. delta is now - prev, or null if either side has no score
overall_grade object { prev, now } — letter grades don't subtract, so there's no delta
components array { component_type, score: { prev, now, delta } }, current run's components first, then any that only the earlier run had
criteria array { component_type, key, label, prev, now, delta } — the union of criterion keys per component. A key scored on only one side has null on the other and a null delta. Join key to GET /sites/{siteId}/rubric
recommendations object Four buckets, below
matching string Always best_effort_title — how the two runs' recommendations were paired

Recommendation buckets (new, still_open and resolved hold recommendations from the current analysis; dropped holds them from the earlier one), each in the same shape as recommendations on the analysis itself:

Bucket Meaning
new The current run raised it and the earlier run didn't
still_open Both runs raised it and it's still pending/in_progress — your edits didn't address it
resolved Both runs raised it and it's now applied, or it's a carried-forward history row (carried_from_recommendation_id)
dropped The earlier run raised it, it was still open then, and the current run doesn't raise it — usually the content changed enough that the grader no longer sees the problem

A recommendation that both runs raised but you dismissed appears in no bucket: it's neither progress nor outstanding work.

Matching is best-effort. Nothing links a recommendation from one run to "the same" recommendation in the next — each analysis writes fresh rows. Recommendations are paired by target_field plus a normalized title (lowercased, punctuation stripped, whitespace collapsed), so wording churn like Lead the title with the primary keyword.Lead the title with the PRIMARY keyword still matches, but a genuine rewrite reads as one dropped plus one new. Treat the buckets as a summary, not a ledger.

Two more caveats:

  • Scores are only comparable within one curve_shape (the grade curve was recalibrated 2026-07-23). Check curve_shape on both analyses before quoting a delta.
  • comparison can be null — always a 200, never an error. comparison_note says why:
comparison_note When
no_previous_analysis compare=prev and this is the page's first analysis
analysis_incomplete The analysis being returned hasn't completed (it's queued, running or failed), so it has no scores to compare. Poll GET /jobs/{jobId} or re-trigger, then compare. The compare target isn't resolved in this case, so a bad analysis ID won't 404 here

Errors

Status When
400 invalid_request compare is neither prev nor a positive integer
404 not_found No such page in your organization (Page not found), the page has no analysis yet (No analysis found), or compare={analysisId} isn't a completed analysis of this page (Comparison analysis not found)

GET /pages/{pageId}/analyses

Score history for a page — its completed analyses, newest first. This is the "did my edits move the number?" view: run it after an analysis completes to see the new score next to the old ones, per criterion.

Only completed analyses appear. A history is a series of comparable scores, and queued, failed or skipped runs have none. Recommendations aren't included — read those from GET /pages/{pageId}/analysis for the latest run.

Scope: sites:read

Path parameters

Parameter Type Description
pageId integer The page's ID

Query parameters

Parameter Type Default Description
page integer 1 1-indexed page number
limit integer 10 Analyses per page, 120. Capped low because each entry can carry a full component set.
include string criteria criteria attaches each analysis's components; none omits the key entirely for a light list of scores.

Request

curl "https://new.metamonster.ai/api/v1/pages/5001/analyses?limit=5" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": [
    {
      "id": 7200,
      "page_id": 5001,
      "site_id": 42,
      "snapshot_id": 8100,
      "status": "completed",
      "overall_score": 78,
      "overall_grade": "B",
      "estimated_score": 90,
      "estimated_grade": "A",
      "score_source": "audit",
      "content_stale": false,
      "curve_shape": "proportional",
      "triggered_by": null,
      "started_at": "2026-07-19T17:55:00Z",
      "completed_at": "2026-07-19T18:00:00Z",
      "created_at": "2026-07-19T17:55:00Z",
      "updated_at": "2026-07-19T18:00:00Z",
      "components": [
        {
          "id": 6100,
          "analysis_id": 7200,
          "component_type": "title",
          "label": "Title tag",
          "score": 80,
          "grade": "B",
          "criteria_scores": { "…": "…" },
          "rationale": "The title is clear but could include the primary keyword earlier."
        }
      ]
    },
    {
      "id": 7050,
      "page_id": 5001,
      "status": "completed",
      "overall_score": 64,
      "overall_grade": "C",
      "completed_at": "2026-07-05T11:20:00Z",
      "components": []
    }
  ],
  "meta": { "total": 7, "page": 1, "per_page": 5 }
}

Each entry is the same shape as the analysis object in GET /pages/{pageId}/analysis — same fields, same meanings — with two differences:

  • No recommendations. They belong to a single run; get them from the analysis endpoint.
  • components is present only with include=criteria (the default), and is [] for an analysis whose components weren't recorded. Each component carries the same fields as on the analysis endpoint, including criteria_scores — join criteria_scores[].key to GET /sites/{siteId}/rubric to see what a line item was scored against.

meta.total counts every completed analysis for the page, not just this page of results.

Comparing scores: only compare analyses with the same curve_shape — the grade curve was recalibrated on 2026-07-23, so a flat score and a proportional score aren't on the same footing. content_stale: true on the newest entry means the page changed after that run, so its score describes content that no longer exists.

Errors

Status When
400 invalid_request page < 1, limit outside 1–20, or an include value other than criteria/none
404 not_found No such page in your organization. A page that has simply never been analyzed returns 200 with an empty data array.

POST /pages/{pageId}/analyze

Trigger a new SEO analysis for a page. Fully async: guards run synchronously in this request, the actual scoring runs in a worker. Returns 202 with a job id to poll.

Scope: content:write

Path parameters

Parameter Type Description
pageId integer The page's ID

Request body

Field Type Notes
force boolean Optional, default false. Re-run even if the page's content fingerprint hasn't changed since the last analysis. Without it, an unchanged page's job completes with status skipped instead of producing a new analysis — see Jobs.
curl -X POST https://new.metamonster.ai/api/v1/pages/5001/analyze \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Forcing a fresh audit regardless of whether content changed:

curl -X POST https://new.metamonster.ai/api/v1/pages/5001/analyze \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "force": true }'

Response 202

{ "data": { "job_id": "job_an_7301", "status": "queued", "page_id": 5001, "retry_after_seconds": 2 } }
Field Type Description
job_id string Poll with GET /jobs/{jobId}
status string (queued) Always queued on the trigger response
page_id integer The page being analyzed
retry_after_seconds integer Seconds to wait before the first poll (and between polls) — the same hint the job resource reports. Server-controlled; read it rather than hardcoding an interval.

Entitlement guard mirrors the dashboard exactly (the API is not a side door around billing) — three distinct 402 paths, checked in this order:

  1. Active subscription, but the plan's page-audit allowance for this period is used uppayment_required with details: { limit, used, remaining }.
  2. No active subscription, and this isn't the site's homepagepayment_required, no details.
  3. No active subscription, this IS the homepage, but the one-time free preview audit was already usedpayment_required, no details. (The homepage gets exactly one free preview analysis without a subscription — mirrors the dashboard's onboarding flow.)

triggered_by on the resulting analysis is null for key-triggered runs (distinguishing them from dashboard-triggered ones).

The page must have something to analyze: a snapshot, a content version, or at least one active draft. An analysis already in progress for the page (status pending/processing) blocks a second trigger — poll the existing job instead of retrying.

Errors

Status When
400 no_content Nothing to analyze — no snapshot, content version, or active draft (Add content or metadata before analyzing this page.)
401 unauthorized Missing/invalid key
402 payment_required See the three entitlement paths above. details: { limit, used, remaining } only on the plan-allowance path; absent on the other two.
403 forbidden Key lacks content:write
404 not_found No such page in your organization (Page not found)
409 analysis_in_progress An analysis is already running for this page (An analysis is already in progress for this page.) — poll GET /jobs/{jobId} for the existing job instead

POST /sites/{siteId}/analyze

Trigger analyses for up to 50 pages of one site in a single call — the bulk form of POST /pages/{pageId}/analyze. Same guards per page, one 202 for the batch.

Scope: content:write

Every page runs through the same entitlement, content, and in-progress gates as the single trigger, so each one either mints a job or comes back in skipped carrying the reason the single route would have raised as an error. The response is always 202, even when nothing was queued — the outcome is per page, so the status code can't carry it. Branch on meta.queued. Poll the jobs it mints with GET /jobs?ids= — up to 50 per request, so a whole batch is one poll.

Path parameters

Parameter Type Description
siteId integer The site's ID

Request body

Provide exactly one of page_ids or filter — both, or neither, is a 400.

Field Type Notes
page_ids integer[] 1–50 unique page IDs, each ≥ 1. Every ID must be a live (non-deleted) page of this site.
filter "priority" Analyze the site's live priority pages instead of listing IDs, oldest ID first. Capped at 50 — meta.truncated tells you more exist.
force boolean Optional, default false. Applies to every page in the batch: re-run even if the content fingerprint hasn't changed. Without it, an unchanged page's job completes with status skipped — see Jobs.
curl -X POST https://new.metamonster.ai/api/v1/sites/42/analyze \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "page_ids": [5001, 5002, 5003], "force": true }'

Re-auditing everything you've marked priority:

curl -X POST https://new.metamonster.ai/api/v1/sites/42/analyze \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filter": "priority" }'

Pages are queued one at a time, in order, and your plan's page-audit allowance is re-checked for each one. A batch bigger than your remaining allowance therefore partial-queues: the first N pages get jobs and the rest come back as allowance_exhausted. It never overshoots the limit, and it never fails the whole call over it.

Response 202

{
  "data": {
    "jobs": [
      { "page_id": 5001, "job_id": "job_an_7301", "retry_after_seconds": 2 },
      { "page_id": 5002, "job_id": "job_an_7302", "retry_after_seconds": 2 }
    ],
    "skipped": [
      { "page_id": 5003, "reason": "analysis_in_progress", "message": "An analysis is already in progress for this page." }
    ]
  },
  "meta": { "requested": 3, "queued": 2, "skipped": 1, "truncated": false }
}
Field Type Description
data.jobs object[] One entry per queued page, in processing order
data.jobs[].page_id integer The page being analyzed
data.jobs[].job_id string Poll them together with GET /jobs?ids= — one request for the whole batch — rather than looping GET /jobs/{jobId}
data.jobs[].retry_after_seconds integer Seconds to wait between polls — server-controlled, read it rather than hardcoding
data.skipped object[] One entry per page that was not queued. For every reason except error, nothing was written: no analysis row, no allowance spent. error is the exception — the failure can happen after the analysis row was created (only the enqueue failed), so an error page may have consumed an allowance slot without a job to poll.
data.skipped[].page_id integer The page that was skipped
data.skipped[].reason string See the table below
data.skipped[].message string Human-readable reason
meta.requested integer Pages processed — your page_ids length, or how many the filter selected (capped at 50)
meta.queued integer data.jobs.length
meta.skipped integer data.skipped.length
meta.truncated boolean filter matched more than 50 pages and only the first 50 were processed — call again once these finish. Always false for page_ids (more than 50 IDs is a 400).

meta.queued + meta.skipped always equals meta.requested.

Skip reasons

reason Meaning
not_found Not a live page of this site — wrong site, another organization, deleted, or nonexistent (indistinguishable on purpose)
no_content Nothing to analyze: no snapshot, content version, or active draft
analysis_in_progress An analysis is already running for that page — poll its existing job
allowance_exhausted Your plan's page-audit allowance ran out partway through the batch. Everything queued before it still runs.
payment_required No active subscription (only a site's homepage gets the one free preview audit)
error Queuing that page failed unexpectedly. The rest of the batch still ran — retry just this page. Unlike the other reasons, this one can fire after the analysis row was written (only the enqueue failed), so it may have spent an allowance slot without giving you a job id; a retry can also come back analysis_in_progress until that orphaned row clears.

Errors

Only whole-call failures are errors here; a per-page problem is a skipped entry.

Status When
400 invalid_request Both or neither of page_ids/filter, more than 50 IDs, duplicate/non-positive IDs, an unknown filter value, or an unknown field
401 unauthorized Missing/invalid key
403 forbidden Key lacks content:write
404 not_found No such site in your organization (Site not found)
429 rate_limited Rate limit exceeded