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://new.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.
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{ limit, used, remaining }), 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 |
Plan allowance exhausted; details carries { limit, used, remaining } |
| 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 | generation_cap_reached |
Per-analysis generation cap reached (distinct from rate limiting); re-run a full audit for a fresh allowance |
| 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 three 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 |
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 the LLM route the LLM one, 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.
Credits & metering
Rate limiting is the only usage limit on the API today. The plain-data write endpoints (updating page metadata, saving content, and creating drafts) store exactly what you send and don't run AI generation, so they don't consume account credits.
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 doesn't consume credits today, 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) rather than a credit meter. Bulk AI content generation is a dashboard feature for now; if it comes to the API, credit costs will be documented here.
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-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_recommendations) to have them written.- MCP:
write_recommendationsis added;analyze_pageandget_page_reportreturn factor tables;find_pagestakesmin_factors_passing_pct; thechecks_*tools are not available to these organizations. /checks/*endpoints remain as aliases.