Checks

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: true when 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_count counts 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, and box matches "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:

  1. GET /sites/{siteId}/rubric — what the grader measures on this site.
  2. GET /pages/{pageId}/checks — what's mechanically wrong right now.
  3. Fix it: PATCH /pages/{pageId} for keywords, PUT /pages/{pageId}/drafts for title/meta/schema, POST /pages/{pageId}/content for the body.
  4. GET /pages/{pageId}/checks again — confirm your edit resolved the check you were aiming at, and that content_changed_since_last_analysis is now true.
  5. 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_analysis is false, 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