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_pathmigration 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 throughnew_pathhere, 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 throughnew_pathon 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.nullon recommendations predating the additive model.carried_from_recommendation_id(integer | null) — set on display-only history rows carried forward (statusapplied) from a prior analysis. Exclude these when counting or summing this analysis's work.verification(object | null) — AI verification verdict for an applied recommendation;nulluntil verified. Shape:{ addressed: boolean, reasoning: string, placeholders: string[], skipped: boolean, validation_scope: string|null, validated_node_ids: string[]|null }.addressed: falsemeans the applied change did NOT resolve the recommendation and it earned no points.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 it.placeholderslists template fragments (e.g."[Your Company]") a human must replace before publishing.validation_scope/validated_node_idsrecord which content the verdict was formed against;validation_scopeisnullwhen 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 forbody_content-target recommendations when the targeted nodes could be resolved from the current content version;nullfor 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 (statusdraft,pendingorgenerating) supplied the value;draft_ididentifies 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_idis 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). Checkcurve_shapeon both analyses before quoting a delta. comparisoncan benull— always a200, never an error.comparison_notesays 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, 1–20. 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. componentsis present only withinclude=criteria(the default), and is[]for an analysis whose components weren't recorded. Each component carries the same fields as on the analysis endpoint, includingcriteria_scores— joincriteria_scores[].keytoGET /sites/{siteId}/rubricto 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:
- Active subscription, but the plan's page-audit allowance for this period is used up →
payment_requiredwithdetails: { limit, used, remaining }. - No active subscription, and this isn't the site's homepage →
payment_required, nodetails. - No active subscription, this IS the homepage, but the one-time free preview audit was already used →
payment_required, nodetails. (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 |