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://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. Send Content-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 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 { limit, used, remaining }), 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 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

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 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}/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_recommendations) to have them written.
  • MCP: write_recommendations is added; analyze_page and get_page_report return factor tables; find_pages takes min_factors_passing_pct; the checks_* tools are not available to these organizations.
  • /checks/* endpoints remain as aliases.