Checks
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://new.metamonster.ai/api/v1/pages/512/checks \
-H "Authorization: Bearer mm_YOUR_API_KEY"
The checks
Returned in this order, always all fifteen:
key |
value |
target |
Status rules |
|---|---|---|---|
title_present |
boolean | — | fail if the title is empty |
title_length |
characters | 30-60 | pass 30–60 · warn 20–29 or 61–70 · fail otherwise (including 0) |
title_has_primary_keyword |
boolean | — | pass / fail |
meta_description_present |
boolean | — | fail if empty |
meta_description_length |
characters | 120-160 | pass 120–160 · warn 90–119 or 161–180 · fail otherwise |
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. |
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_length", "status": "pass", "value": 44, "target": "30-60 characters", "message": "Title is 44 characters." },
{ "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_length", "status": "warn", "value": 100, "target": "120-160 characters", "message": "Meta description is 100 characters; aim for 120-160." },
{ "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": 8, "warn": 6, "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
}
}
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 |