Checks
Subscription required, except for the quick check. Every endpoint on this page needs an active subscription except the free quick check (
GET /pages/{pageId}/checks/previewandPOST /checks/quick). Without one the request is refused with402 payment_required(Pick a plan to keep going.) before the endpoint runs, reads included. See Conventions.
Checks are hygiene, not the grade. They're the mechanical things a machine can settle without a model — is there a title, is it the right length, does the primary keyword appear in it, is there exactly one H1, is the JSON-LD valid, does the page link anywhere. The grade (overall_score, overall_grade) comes from the LLM grading the page against the site's rubric. A page can pass every check here and still grade a C: checks can't judge whether the content is useful, differentiated, or better than what's already ranking.
What checks are for is the free, instant half of the loop. They cost no analysis allowance, run no model, and answer in one round-trip, so you can fix the obvious misses first and spend an analysis run on the judgement calls.
Fields are resolved exactly as the analysis worker resolves them: an active draft beats the latest crawled snapshot (a blank draft falls back to the snapshot), and the body is the newest content version rendered to markdown. So the checks describe the same content a run would score — and sources tells you where each value came from, which answers "did my draft count?".
Scope: sites:read
GET /pages/{pageId}/checks
Path parameters
| Parameter | Type | Description |
|---|---|---|
pageId |
integer | The page's ID |
Request
curl -s https://app.metamonster.ai/api/v1/pages/512/checks \
-H "Authorization: Bearer mm_YOUR_API_KEY"
The checks
Returned in this order, always all thirteen:
Changed 2026-10-01.
title_lengthandmeta_description_lengthwere removed from this endpoint (it returned fifteen checks before). Length is how a title or snippet displays, not how a page ranks, so it is no longer a check on any surface. This is a deliberate exception to the otherwise additive-only contract. Analyses stored before the change may still carry rows with those two keys; treat an unknown key as ignorable.
key |
value |
target |
Status rules |
|---|---|---|---|
title_present |
boolean | — | fail if the title is empty |
title_has_primary_keyword |
boolean | — | pass / fail |
meta_description_present |
boolean | — | fail if empty |
meta_description_has_primary_keyword |
boolean | — | pass / warn (a miss here is worth fixing, not a defect) |
h1_present |
boolean | — | fail if the body has no H1 |
h1_single |
count | exactly 1 | pass 1 · warn more than 1 |
h1_has_primary_keyword |
boolean | — | pass / warn |
intro_has_primary_keyword |
boolean | within the first 100 words | pass / warn |
word_count |
words | 300+ | pass ≥ 300 · warn 100–299 · fail < 100 |
schema_present |
boolean | — | pass if JSON-LD is set · warn if absent |
schema_valid |
boolean | — | fail when the schema is present but invalid (the message carries the first error and the error count) |
internal_links |
count | 2+ internal links | pass ≥ 2 · warn 1 · fail 0 |
outbound_links |
count | — | informational, always pass |
not_applicable means the check couldn't run, because a prerequisite is missing — and each defect is reported exactly once, on the check that owns it:
| Missing | Checks that go not_applicable |
|---|---|
| No primary keyword on the page | all four *_has_primary_keyword / intro_* checks |
| No body content version yet | h1_present, h1_single, h1_has_primary_keyword, intro_has_primary_keyword, word_count |
| Body with no H1 | h1_single, h1_has_primary_keyword (the failure is on h1_present) |
| No schema | schema_valid (the warning is on schema_present) |
| Page has never been crawled | internal_links, outbound_links |
So summary.fail counts distinct problems, not knock-on effects. A crawled page that genuinely links nowhere still reports internal_links: 0 (fail) — not_applicable is reserved for "we don't know".
Other fields
| Field | Description |
|---|---|
fields |
The resolved values the checks ran against: title, meta_description, h1 (text of the first H1), word_count, primary_keyword. |
sources |
Provenance per field — { source: "draft", draft_id }, { source: "snapshot" }, { source: "none" }, and body_content: { source: "content_version", content_version_id }. Same shape as an analysis' analyzed_content.sources. |
last_analysis |
Newest completed analysis (id, overall_score, overall_grade, estimated_score, estimated_grade, analyzed_at), or null if the page has never been analyzed. |
content_changed_since_last_analysis |
true when the current content fingerprint differs from the one stored on last_analysis — a re-analysis would see something new. false when nothing changed (re-analyzing would likely be skipped). null when it can't be told: no analysis yet, or a legacy analysis with no usable fingerprint. |
new_version_published |
The notice the editor shows when a new version of the page was published to the site's files since it was last edited in MetaMonster: { at, changes, link } (changes are plain-language lines; link is { url, label } or null). null when there is none. Relay it before editing: the marketer chooses whether to keep their edits or use the new version. |
The fingerprint covers title, meta description, schema, body markdown, and the page's keywords — the same six fields the analysis worker fingerprints. Where a value came from is not part of it, so promoting an identical draft to a published snapshot doesn't count as a change.
One false positive to know about. A page whose only body is crawled snapshot markdown (no content version written yet) can report
content_changed_since_last_analysis: truewhen nothing actually changed: the analysis worker back-fills a content version from that snapshot markdown before fingerprinting, and this read-only endpoint can't write one, so it fingerprints an empty body against the worker's non-empty one. The first analysis run creates the version and the two agree from then on. It never goes the other way — a real edit is never reported as "unchanged".
Response 200
{
"data": {
"page_id": 512,
"checks": [
{ "key": "title_present", "status": "pass", "value": true, "target": null, "message": "Title is set." },
{ "key": "title_has_primary_keyword", "status": "pass", "value": true, "target": null, "message": "Primary keyword \"custom shipping boxes\" appears in the title." },
{ "key": "meta_description_present", "status": "pass", "value": true, "target": null, "message": "Meta description is set." },
{ "key": "meta_description_has_primary_keyword", "status": "pass", "value": true, "target": null, "message": "Primary keyword \"custom shipping boxes\" appears in the meta description." },
{ "key": "h1_present", "status": "pass", "value": true, "target": null, "message": "Body has an H1: \"Custom shipping boxes, made in Charleston\"." },
{ "key": "h1_single", "status": "warn", "value": 2, "target": "exactly 1", "message": "Body has 2 H1 headings; keep one and demote the rest to H2." },
{ "key": "h1_has_primary_keyword", "status": "pass", "value": true, "target": null, "message": "Primary keyword \"custom shipping boxes\" appears in the H1." },
{ "key": "intro_has_primary_keyword", "status": "warn", "value": false, "target": "within the first 100 words", "message": "Primary keyword \"custom shipping boxes\" does not appear in the first 100 words." },
{ "key": "word_count", "status": "warn", "value": 214, "target": "300+ words", "message": "Body has 214 words; thin for a page meant to rank. Aim for 300+." },
{ "key": "schema_present", "status": "warn", "value": false, "target": null, "message": "No JSON-LD structured data. Adding the right schema type makes the page eligible for rich results." },
{ "key": "schema_valid", "status": "not_applicable", "value": null, "target": null, "message": "No schema to validate." },
{ "key": "internal_links", "status": "warn", "value": 1, "target": "2+ internal links", "message": "Only 1 internal link out of this page; link to more related pages." },
{ "key": "outbound_links", "status": "pass", "value": 3, "target": null, "message": "3 external links out of this page (informational — there is no target)." }
],
"summary": { "pass": 7, "warn": 5, "fail": 0, "not_applicable": 1 },
"fields": {
"title": "Custom Shipping Boxes in Charleston | Box Co",
"meta_description": "Custom shipping boxes made in Charleston. Short runs of corrugated mailers and cartons, no minimums.",
"h1": "Custom shipping boxes, made in Charleston",
"word_count": 214,
"primary_keyword": "custom shipping boxes"
},
"sources": {
"title": { "source": "draft", "draft_id": 8812 },
"meta_description": { "source": "snapshot" },
"schema": { "source": "none" },
"body_content": { "source": "content_version", "content_version_id": 4471 }
},
"last_analysis": {
"id": 9032,
"overall_score": 74,
"overall_grade": "C",
"estimated_score": 88,
"estimated_grade": "B+",
"analyzed_at": "2026-08-16T14:22:03.118Z"
},
"content_changed_since_last_analysis": true,
"new_version_published": null
}
}
Notes on the numbers
- Link counts come from the page's latest crawl (the same rows as
GET /pages/{pageId}/links), not from the body markdown you may have just written. A link you added in a draft or a new content version won't show up here until the page is recrawled. A link counts as internal when the crawler resolved it to one of your pages, or when its href is relative or on the site's own domain. word_countcounts the body document, not the markdown syntax around it — headings, list markers and link URLs aren't words.h1_*reads the body, not an<h1>tag from the crawl. H1 is body content in MetaMonster, so a heading you add in a content version counts immediately.- Keyword matching is a case-insensitive, whitespace-collapsed substring match — "Custom Shipping Boxes" matches
custom shipping boxes, andboxmatches "boxes". It's deliberately loose: it's a placement check, not a density metric.
Using it
The intended loop, all of it free until the last step:
GET /sites/{siteId}/rubric— what the grader measures on this site.GET /pages/{pageId}/checks— what's mechanically wrong right now.- Fix it:
PATCH /pages/{pageId}for keywords,PUT /pages/{pageId}/draftsfor title/meta/schema,POST /pages/{pageId}/contentfor the body. GET /pages/{pageId}/checksagain — confirm your edit resolved the check you were aiming at, and thatcontent_changed_since_last_analysisis nowtrue.POST /pages/{pageId}/analyze— spend the run on the judgement the checks can't make.
Two things worth branching on:
- If
content_changed_since_last_analysisisfalse, re-analyzing will likely be skipped as a no-op — change something first. - If
summary.fail > 0, fix those before spending a run: the grader will dock you for the same misses, and you'd be paying a model to tell you what this endpoint just told you for free.
Errors
| Status | When |
|---|---|
401 unauthorized |
Missing/invalid key |
403 forbidden |
Key lacks sites:read |
404 not_found |
No such page in your organization |
429 rate_limited |
Rate limit exceeded |
The /checks/* endpoints and the new analysis
For organizations on the new analysis (factors), GET /pages/{pageId}/checks/analysis, POST /pages/{pageId}/checks/analyze, POST /pages/{pageId}/checks/recommendations and GET /sites/{siteId}/checks/rubric are aliases of the endpoints that were switched in place (/analysis, /analyze, /rubric). They keep their payloads unchanged, so existing clients keep working. New code should use the main endpoints, which return the same checks analysis for these organizations, plus engine: "checks", factors_passing and factors_total.
POST /pages/{pageId}/checks/recommendations is also how recommendations get written when an analysis reports recommendations_status: "not_requested" (MCP: write_fixes). It responds 202 with recommendations_status: "generating"; read the result from GET /pages/{pageId}/analysis once it reads ready or failed.
GET /pages/{pageId}/checks (the free, instant hygiene check) is unchanged.
The free quick check
Two endpoints run the deterministic factors (no model call, no allowance, nothing stored) and answer with or without a subscription. Both return checks, factors, summary and pending_checks (the AI-judged checks, which only a full analysis runs). MCP: quick_check_page.
GET /pages/{pageId}/checks/previewchecks a page MetaMonster has, in its current state.POST /checks/quickchecks content you send, or a public URL it fetches, with no site and no page. Send at most one ofmarkdown(preferred; YAML frontmatter allowed) orhtml(the page's full HTML, head included), plus optionaltitle,meta_description,h1,primary_keyword,secondary_keywords,page_typeandurl. A field beats the frontmatter key for the same value. A Markdown body with no#heading has no H1 unlessh1or frontmatterh1/heading/titlenames it. Content is graded with the default factor settings against a stand-in site (the domain fromurl, when given). What it can't answer is listed innot_checkedwith areason:no_primary_keyword,needs_site,needs_full_page(sendhtml), ornot_supplied(withmissing). The response'ssourcesays what was checked:markdown,htmlorurl.markdownorhtmlover 500 KB answers413 body_too_large; frontmatter that doesn't parse,400 invalid_frontmatter.- URL mode: send
urlwith neithermarkdownnorhtml, and MetaMonster fetches the page with a plain HTTP GET: no JavaScript is run, so a page that builds its content in the browser is checked as the HTML its server returns (send that page'shtmlinstead). http or https on the standard ports only, at most 3 redirects, 10 seconds and 2 MB, HTML responses only.titleandmeta_descriptionstill beat the fetched page's;h1is Markdown only. The response addsfetched:url,final_url(after redirects; the stand-in site's domain and path),status,bytesandtitle(the page's title, or null). Nothing fetched is stored. Errors, all422:url_blocked(the host is or resolves to a private or reserved address, a non-standard port, or a redirect goes there),url_fetch_failed(DNS, connection, timeout, too many redirects, or a non-2xx status, named in the message),url_not_html,url_too_large. URL fetches are limited per organization per UTC day; over the limit the call answers429 quick_check_url_limituntil midnight UTC. Content sent asmarkdownorhtmlis not limited this way.
See the OpenAPI spec for the full shapes.