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{ "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 — 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/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) — 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 on message).
  • message — a human-readable explanation. On 4xx errors it's specific; on 5xx it's always the generic Internal 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_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 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.