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 /health→{ "ok": true }GET /me→{ "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— total number of items matching the request (across all pages).page— the page you requested.per_page— 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/result model. - On
completed, the job'sresultcarries what you need to continue (e.g. the newanalysis_idand score, or the recrawl'ssnapshot_id) — 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 (e.g. 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} → an analysis; job_cr_{id} → 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— a stable, machine-readable slug (switch on this, not onmessage).message— a human-readable explanation. On4xxerrors it's specific; on5xxit's always the genericInternal server error(internal details are never leaked).details— an optional object carrying machine-readable context for specific codes. 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 that 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.