Conventions

Conventions

These rules hold across every endpoint. Read this once and the reference pages will make sense at a glance.

Base URL and versioning

https://app.metamonster.ai/api/v1

The API is versioned in the path (/v1). Within a version, we treat the response shapes as a contract: we may add fields, but we won't remove or repurpose existing ones without a new version. Adding a field is not considered a breaking change, so write your integration to ignore unknown fields.

Requests

  • All requests use HTTPS.
  • Authenticate with a Bearer token on every request except GET /health. See Authentication.
  • Request bodies (for POST/PATCH/PUT) are JSON. Send Content-Type: application/json.

Subscription required

Every authenticated endpoint needs an active subscription, except the free quick check and the site list. That includes other reads, GET /me, and every MCP tool but quick_check_page and list_sites. The quick check's two endpoints, POST /checks/quick (content you send, or a public URL it fetches) and GET /pages/{pageId}/checks/preview (a saved page), and GET /sites answer with or without a subscription. If the key's organization has no active subscription, any other request is refused before the endpoint runs:

{
  "error": {
    "code": "payment_required",
    "message": "Pick a plan to keep going.",
    "details": {
      "reason": "no_plan",
      "plans": [{ "name": "Solo", "monthly_usd": 49, "sites": 1, "pages_per_month": 10, "drift": false }]
    }
  }
}

with HTTP status 402. details.plans lists the plans on sale, cheapest first (sites and pages_per_month are null when unlimited); pick one at https://app.metamonster.ai/pricing?plan=<name>. It is [] if the plan list can't be read.

  • "Active" means the subscription's status is active or trialing. A past_due subscription (a failed payment) keeps working for 15 days from the first failed payment, so there is time to update the card. An organization that never subscribed, whose subscription is canceled or expired, or whose past_due grace period has run out, gets the 402.
  • The check runs after authentication and rate limiting and before the scope check. So a bad key is still 401, a request over the rate limit is still 429 (and still counts against the key's budget), and an organization without a subscription gets 402 whatever scopes its key carries.
  • Nothing is read, written, queued or charged on a 402.
  • Access comes back as soon as the organization subscribes again in the dashboard (Settings → Billing). Keys and connected apps keep working; there is nothing to recreate.
  • Only the two unauthenticated endpoints are exempt: GET /health and GET /openapi.yaml.

The reference pages do not repeat this on every endpoint. Where a page lists 402 payment_required among an endpoint's errors, it is describing a second cause on top of this one, such as a used-up plan allowance.

payment_required is not a transient failure: don't retry it. Tell the user their MetaMonster plan needs attention.

The response envelope

Detail (single-object) endpoints return the resource under a data key:

{ "data": { "id": 42, "domain": "example.com" } }

List endpoints return an array under data plus a meta object with pagination info:

{
  "data": [ /* ... */ ],
  "meta": { "total": 128, "page": 1, "per_page": 15 }
}

Two endpoints are intentionally unwrapped because they're not resources:

  • GET /health returns { "ok": true }
  • GET /me returns { "name", "prefix", "scopes" }

Pagination

List endpoints use offset pagination with two query parameters:

Parameter Meaning Notes
page Which page of results, 1-indexed Minimum 1. Defaults to 1.
limit How many items per page Minimum 1, maximum 100. Default varies per endpoint.

Default limit by endpoint:

Endpoint Default limit
GET /sites 15
GET /sites/{siteId}/pages 50
GET /pages/{pageId}/snapshots 20
GET /pages/{pageId}/content-versions 20

The meta object echoes back what you got:

"meta": { "total": 128, "page": 2, "per_page": 50 }
  • total is the total number of items matching the request, across all pages.
  • page is the page you requested.
  • per_page is the limit that was applied.

To page through everything, increment page until page * per_page >= total.

A value outside the allowed range (e.g. limit=500 or page=0) returns 400 invalid_request.

A handful of list-style responses are not paginated because they're naturally small: GET /pages/{pageId}/drafts and the components/recommendations arrays inside GET /pages/{pageId}/analysis return complete arrays with no meta. Two more list responses carry a meta that isn't the pagination one, because neither is a page of a collection. GET /jobs?ids= is a lookup by id (meta: { requested, found }, one entry per id you asked for), and GET /pages/{pageId}/links is a hard-capped dump (meta: { total, truncated }, where total counts the returned rows). Neither takes page/limit.

The async trigger pattern (202 + job)

Three endpoints trigger work that takes longer than a single request should block for: POST /pages/{pageId}/analyze, its bulk form POST /sites/{siteId}/analyze, and POST /pages/{pageId}/recrawl. They follow the same shape, one pattern for all triggered work, and any future async trigger endpoints will follow it too:

  1. The trigger call runs its guards synchronously (entitlements, in-progress checks, etc.) and, on success, responds 202 Accepted with a virtual job id:
    { "data": { "job_id": "job_an_7301", "status": "queued", "page_id": 5001, "retry_after_seconds": 2 } }
    
    The bulk trigger runs those same guards per page and so is always 202: each page lands in data.jobs[] with its own job_id or in data.skipped[] with the reason the single-page route would have raised as an error. Branch on meta.queued, not on the status code.
  2. Poll GET /jobs/{jobId} until status leaves queued/processing, sleeping retry_after_seconds between polls. Every non-terminal job response repeats that hint in the body and as a standard Retry-After header; terminal responses send no header and report retry_after_seconds: null. Today the hint is 2 seconds for analysis jobs and 5 for recrawls, but treat it as server-controlled: read it, don't hardcode it. See Jobs for the full status and result model.
  3. On completed, the job's result carries what you need to continue (e.g. the new analysis_id and score, or the recrawl's snapshot_id), so you don't need a second lookup to know the outcome.

Polling doesn't burn your general request budget: the job endpoints spend a separate, much larger rate-limit bucket. Waiting on several jobs at once, say after POST /sites/{siteId}/analyze? GET /jobs?ids= polls up to 50 of them in one request. See Jobs.

There is no jobs table. A job id encodes which backing row it wraps (job_an_{id} wraps an analysis; job_cr_{id} wraps a crawl), so job status is always that row's own status, live.

Errors

Errors on /api/v1/* endpoints return a consistent envelope with the appropriate HTTP status:

{ "error": { "code": "not_found", "message": "Page not found" } }
  • code is a stable, machine-readable slug. Switch on this, not on message.
  • message is a human-readable explanation. On 4xx errors it's specific; on 5xx it's always the generic Internal server error, since internal details are never leaked.
  • details is an optional object carrying machine-readable context for specific codes. It's only present when the code defines it (e.g. payment_required carries { reason: "limit_reached", limit, used, remaining, … } when a plan allowance is used up, and { reason: "no_plan", plans } when the organization has no active subscription), absent otherwise, and always absent on 5xx.

Error codes

HTTP status code Typical cause
400 invalid_request Malformed or invalid parameters / body
401 unauthorized Missing, invalid, revoked, or expired key
402 payment_required The organization has no active subscription (any authenticated endpoint; see Subscription required), or a plan allowance is used up (then details carries { limit, used, remaining } where the endpoint defines it)
403 forbidden Key lacks the required scope
404 not_found Resource doesn't exist, or isn't yours
409 conflict Write conflicts with current state (e.g. optimistic-concurrency mismatch)
409 keyword_generation_in_progress A keyword write landed while keyword generation is running for the site; retry after it completes
400 no_content Nothing to act on, e.g. triggering an analysis with no snapshot/content version/draft, or recrawling a page with no URL
400 no_content_to_validate A recommendation resolve/recheck couldn't resolve any content to validate against
409 analysis_in_progress An analysis is already running for this page; poll the existing job (GET /jobs/{jobId}) instead of retriggering
409 crawl_in_progress A crawl (single-page recrawl or full-site crawl) is already running for this site; poll the existing job instead
409 path_conflict A recrawl's new_path collides with another live page's URL on the same site
409 already_resolved A recommendation dismiss/resolve targeted a recommendation that's already dismissed or applied
409 not_applied A recommendation recheck targeted a recommendation that isn't currently applied
429 rate_limited Too many requests; see Rate limiting
429 context_documents_cap_reached The organization's monthly Context Library words limit is used up; details carries limit, used, words and resets_at. Not transient: wait for resets_at
429 generation_cap_reached Per-analysis generation cap reached (distinct from rate limiting); re-run a full audit for a fresh allowance
422 url_blocked, url_fetch_failed, url_not_html, url_too_large POST /checks/quick with only a url: the address is private or reserved, the fetch failed or answered non-2xx, the page isn't HTML, or it is over 2 MB. Send the page's html instead
429 quick_check_url_limit The organization's daily URL fetches for POST /checks/quick are used up; resets at midnight UTC. Sending markdown or html still works
429 revision_cap_reached A fix draft has had its 3 revisions; accept and edit it, or reject and generate again
503 engine_paused The checks engine (POST /pages/{pageId}/checks/analyze) is paused for maintenance; nothing was queued, retry later
5xx internal_error Something went wrong on our end

404 instead of 403 for other orgs. If you request a resource that exists but belongs to a different organization, or pass a malformed ID, you get 404 not_found, never a 403. This is deliberate: it prevents the API from confirming whether an ID exists in someone else's account.

Every /api/v1/* endpoint uses this error envelope, including GET /health and GET /me. Their success responses are unwrapped ({ "ok": true } and the key identity), but errors always come back as { "error": { ... } }.

Rate limiting

Requests are rate-limited per API key using a fixed window of 60 seconds. There are independent buckets, so one can't starve the others:

Bucket Endpoints Limit (subject to change)
Default Everything except job polling 120 requests / 60s
Jobs GET /jobs/{jobId}, GET /jobs?ids= 600 requests / 60s
LLM POST /pages/{pageId}/checks/recommendations 6 requests / 60s
Keywords GET /keywords/research 30 requests / 60s
Documents POST /sites/{siteId}/documents 20 requests / 60s

Job polling gets its own budget because waiting on async work is the documented pattern: a poll loop should never eat into the requests you need for real work. The LLM bucket is the mirror image. That route makes a model call on every request and debits no page-audit allowance, so it gets a small budget of its own rather than a share of yours. Every bucket is per key, and one key's usage never affects another's.

Every authenticated response includes headers describing the budget that request spent. On a job poll they describe the jobs bucket, on a route with its own bucket that bucket, everywhere else the default:

Header Meaning
X-RateLimit-Limit Max requests allowed in the current window
X-RateLimit-Remaining Requests remaining in the current window
X-RateLimit-Reset Unix timestamp (seconds) when the window resets

When you exceed the limit, you get 429 rate_limited with a Retry-After header (seconds to wait). Back off until the window resets. Retry-After also appears on 200 responses from GET /jobs/{jobId} while a job is still running; there it's a poll-interval hint, not a throttle. See the async pattern.

Requests that fail authentication are separately throttled per IP address to limit key-guessing. If you see 429 with the message Too many unauthenticated requests, you're sending too many requests with a bad or missing key. Fix the key and slow down.

The unauthenticated endpoints (GET /health, GET /openapi.yaml) consume that same per-IP budget on every request, so no route on the API is unthrottled.

Metering

Using the API at all takes an active subscription (see Subscription required). With one, rate limiting is the only usage limit on most endpoints. The plain-data write endpoints (updating page metadata, saving content, and creating drafts) store exactly what you send and don't run AI generation.

The recommendation verbs (resolve, recheck) are a partial exception: they call an AI validator (Claude Haiku) to check whether the recommendation was addressed. This is verification, not content generation. It isn't metered, but it's the one write path on the API that touches an LLM. Triggering an analysis (POST /pages/{pageId}/analyze) also runs the full scoring pipeline, gated by your plan's page-audit allowance (402 payment_required) and counts a page once per billing period.

Timestamps

All timestamps are ISO 8601 strings in UTC, e.g. "2026-07-20T09:30:00Z". Fields that can be empty are null.

IDs

Resource IDs (site_id, page_id, snapshot_id, etc.) are integers. A non-integer or non-positive ID in a path resolves to 404 not_found.

Changelog

2026-11-07: the quick check fetches a URL

POST /checks/quick (the MCP quick_check_page tool) takes url alone: MetaMonster fetches the page with a plain HTTP GET, without running JavaScript, and checks the HTML its server returns. It is still free, stores nothing and spends no allowance. URL fetches are limited per organization per UTC day (429 quick_check_url_limit); markdown and html are not. Failed fetches answer 422 with url_blocked, url_fetch_failed, url_not_html or url_too_large. Every response now carries source (markdown, html or url), and URL mode adds fetched (url, final_url, status, bytes). In MCP, the result names what was checked, and when no primary_keyword was sent it says how many keyword checks were skipped and tells Claude to ask the user for the target keyword.

2026-11-06: GET /sites answers without a subscription

GET /sites (the MCP list_sites tool) now answers for an organization with no active subscription, like the free quick check. It is a plain read: nothing is charged. Every other endpoint still answers 402 payment_required without one. In MCP, list_sites adds a line saying that quick_check_page is free and the other tools need a plan, and plan_required: true in its structured result.

2026-10-14: every endpoint needs an active subscription

Until now only the endpoints that started paid work (adding a site, analysis, briefs, recrawls, keyword research, opportunities) checked the organization's subscription. Now every authenticated endpoint does, reads included.

  • An organization whose subscription is not active or trialing gets 402 payment_required (Pick a plan to keep going.) from every endpoint except GET /health, GET /openapi.yaml and the free quick check (POST /checks/quick, GET /pages/{pageId}/checks/preview). GET /me is included. See Subscription required.
  • A failed payment does not cut access at once: a past_due subscription keeps working for 15 days from the first failed payment, so there is time to update the card. After that it gets the 402 too.
  • The 402 comes before the scope check, so it no longer depends on which endpoint you call or which scopes the key has.
  • Endpoints that used to word this 402 their own way now answer with the one message above. The code was payment_required before and still is, with one exception: POST /sites/{siteId}/proposals/evaluate used to answer subscription_required and now answers payment_required like the rest.
  • POST /sites/{siteId}/analyze no longer reports payment_required as a per-page skip reason; the whole call is refused instead.
  • MCP: every tool returns the same payment_required error for such an organization. Connecting and listing tools still work.
  • Nothing changes for an organization with an active subscription.

2026-10-04: the new analysis (factors)

Organizations moved to the new analysis get factor-based responses in place, on the same endpoints. The rollout is gradual. Each analysis-bearing response carries engine: "checks"; its absence means the legacy scored analysis, whose responses are unchanged.

  • POST /pages/{id}/analyze and POST /sites/{id}/analyze queue factor analyses (job_ck_… job ids). A new skip reason, engine_paused, can appear in the bulk response.
  • GET /pages/{id}/analysis returns factors, checks and factors_passing / factors_total; compare is ignored. GET /pages/{id}/analyses lists factor-analysis history; include is accepted and ignored. A page with only a legacy analysis returns 404 No analysis found.
  • GET/PATCH /pages/{id}, /brief and /report embed analysis: { id, analyzed_at, factors_passing, factors_total, recommendations_status } in place of latest_analysis (or the score sections).
  • GET /sites/{id}/pages: min_factors_passing_pct replaces grade (400 if sent); has_recommendations counts the new recommendations.
  • GET /sites/{id}/opportunities: an analysis block replaces grade, and rows carry analysis. A row's score stays the demand / opportunity rank.
  • GET /sites/{id}/rubric returns the factor list.
  • Removed for these organizations: overall_score, overall_grade, estimated_score, estimated_grade, point_value, score_source.
  • recommendations_status can be not_requested: call POST /pages/{id}/checks/recommendations (MCP: write_fixes) to have them written.
  • MCP: the MCP serves only this analysis. analyze_page and get_page_report return factor tables with fixes; write_fixes writes them when fixes_status is not_requested; find_pages takes min_factors_passing_pct.
  • /checks/* endpoints remain as aliases.