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. SendContent-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
activeortrialing. Apast_duesubscription (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 iscanceledorexpired, or whosepast_duegrace period has run out, gets the402. - 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 still429(and still counts against the key's budget), and an organization without a subscription gets402whatever 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 /healthandGET /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 /healthreturns{ "ok": true }GET /mereturns{ "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 }
totalis the total number of items matching the request, across all pages.pageis the page you requested.per_pageis thelimitthat 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}/draftsand thecomponents/recommendationsarrays insideGET /pages/{pageId}/analysisreturn complete arrays with nometa. Two more list responses carry ametathat 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), andGET /pages/{pageId}/linksis a hard-capped dump (meta: { total, truncated }, wheretotalcounts the returned rows). Neither takespage/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:
- The trigger call runs its guards synchronously (entitlements, in-progress checks, etc.) and, on success, responds
202 Acceptedwith a virtual job id:
The bulk trigger runs those same guards per page and so is always{ "data": { "job_id": "job_an_7301", "status": "queued", "page_id": 5001, "retry_after_seconds": 2 } }202: each page lands indata.jobs[]with its ownjob_idor indata.skipped[]with the reason the single-page route would have raised as an error. Branch onmeta.queued, not on the status code. - Poll
GET /jobs/{jobId}untilstatusleavesqueued/processing, sleepingretry_after_secondsbetween polls. Every non-terminal job response repeats that hint in the body and as a standardRetry-Afterheader; terminal responses send no header and reportretry_after_seconds: null. Today the hint is2seconds for analysis jobs and5for recrawls, but treat it as server-controlled: read it, don't hardcode it. See Jobs for the full status and result model. - On
completed, the job'sresultcarries what you need to continue (e.g. the newanalysis_idand score, or the recrawl'ssnapshot_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" } }
codeis a stable, machine-readable slug. Switch on this, not onmessage.messageis a human-readable explanation. On4xxerrors it's specific; on5xxit's always the genericInternal server error, since internal details are never leaked.detailsis an optional object carrying machine-readable context for specific codes. It's only present when the code defines it (e.g.payment_requiredcarries{ 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 on5xx.
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 |
404instead of403for other orgs. If you request a resource that exists but belongs to a different organization, or pass a malformed ID, you get404 not_found, never a403. 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
activeortrialinggets402 payment_required(Pick a plan to keep going.) from every endpoint exceptGET /health,GET /openapi.yamland the free quick check (POST /checks/quick,GET /pages/{pageId}/checks/preview).GET /meis included. See Subscription required. - A failed payment does not cut access at once: a
past_duesubscription keeps working for 15 days from the first failed payment, so there is time to update the card. After that it gets the402too. - The
402comes 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
402their own way now answer with the one message above. The code waspayment_requiredbefore and still is, with one exception:POST /sites/{siteId}/proposals/evaluateused to answersubscription_requiredand now answerspayment_requiredlike the rest. POST /sites/{siteId}/analyzeno longer reportspayment_requiredas a per-page skip reason; the whole call is refused instead.- MCP: every tool returns the same
payment_requirederror 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}/analyzeandPOST /sites/{id}/analyzequeue factor analyses (job_ck_…job ids). A new skip reason,engine_paused, can appear in the bulk response.GET /pages/{id}/analysisreturns factors, checks andfactors_passing/factors_total;compareis ignored.GET /pages/{id}/analyseslists factor-analysis history;includeis accepted and ignored. A page with only a legacy analysis returns404 No analysis found.GET/PATCH /pages/{id},/briefand/reportembedanalysis: { id, analyzed_at, factors_passing, factors_total, recommendations_status }in place oflatest_analysis(or the score sections).GET /sites/{id}/pages:min_factors_passing_pctreplacesgrade(400 if sent);has_recommendationscounts the new recommendations.GET /sites/{id}/opportunities: ananalysisblock replacesgrade, and rows carryanalysis. A row'sscorestays the demand / opportunity rank.GET /sites/{id}/rubricreturns the factor list.- Removed for these organizations:
overall_score,overall_grade,estimated_score,estimated_grade,point_value,score_source. recommendations_statuscan benot_requested: callPOST /pages/{id}/checks/recommendations(MCP:write_fixes) to have them written.- MCP: the MCP serves only this analysis.
analyze_pageandget_page_reportreturn factor tables with fixes;write_fixeswrites them whenfixes_statusisnot_requested;find_pagestakesmin_factors_passing_pct. /checks/*endpoints remain as aliases.