Jobs

Jobs

A job is a virtual resource representing async work triggered through the API — a page analysis or a page recrawl. There's no jobs table: GET /jobs/{jobId} reads directly from the row actually doing the work (a page_analyses row for an analysis, a crawls row for a recrawl) and reports its status. The job id encodes which one it is: job_an_{id} wraps a page_analyses row, job_cr_{id} wraps a crawls row.

You get a job id back from POST /pages/{pageId}/analyze or POST /pages/{pageId}/recrawl (both 202). Poll GET /jobs/{jobId} until status leaves queued/processing, sleeping retry_after_seconds between polls — the trigger response and every job response carry that hint, and non-terminal job responses repeat it in a Retry-After header.

A batch trigger — POST /sites/{siteId}/analyze — mints one job per queued page in data.jobs[]. They're ordinary job_an_{id} jobs, but don't loop the single endpoint over them: GET /jobs?ids= polls up to 50 in one request. Treat the batch as finished when every one of them is terminal.

Polling doesn't consume your general request budget: both job endpoints spend a separate rate-limit bucket (600 req/60s vs. the default 120), and the X-RateLimit-* headers on a job response describe that bucket.

A job_cr_{id} id wraps any crawl row for the org, not just the single-page recrawl that minted it — including a full-site crawl. Full-site crawls are webhook-driven and sit in the internal running status while in flight; GET /jobs/{jobId} reports that as processing, matching the documented enum. A crawl that gets cancelled upstream is reported as failed with error: "Crawl cancelled" — every internal status maps onto the documented enum, so you never have to handle a status you can't find here. Recrawl-triggered jobs are inserted already processing and never pass through queued.

All job endpoints require sites:read.


GET /jobs/{jobId}

Get the current status of an async job.

Scope: sites:read

Path parameters

Parameter Type Description
jobId string The virtual job id returned by a 202 trigger response — job_an_{id} or job_cr_{id}

Request

curl https://new.metamonster.ai/api/v1/jobs/job_an_7301 \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

An analysis job that has completed:

{
  "data": {
    "id": "job_an_7301",
    "type": "analyze",
    "status": "completed",
    "page_id": 5001,
    "site_id": 42,
    "created_at": "2026-08-14T10:00:00Z",
    "started_at": "2026-08-14T10:00:01Z",
    "completed_at": "2026-08-14T10:00:18Z",
    "error": null,
    "retry_after_seconds": null,
    "result": {
      "analysis_id": 7301,
      "overall_score": 82,
      "overall_grade": "B+",
      "score_source": "audit"
    }
  }
}

A recrawl job still in flight:

{
  "data": {
    "id": "job_cr_990",
    "type": "recrawl",
    "status": "processing",
    "page_id": null,
    "site_id": 42,
    "created_at": "2026-08-14T10:05:00Z",
    "started_at": "2026-08-14T10:05:00Z",
    "completed_at": null,
    "error": null,
    "retry_after_seconds": 5,
    "result": {}
  }
}
Field Type Description
id string The virtual job id (job_an_{id} or job_cr_{id})
type string (analyze | recrawl) What kind of work this job represents
status string See Statuses below
page_id integer | null The page this job is for. On a recrawl job there's no page_id column to read directly — it's resolved from the crawl's snapshot, so it's null while the job is queued/processing and only populated once the job completeds. Always present on analyze jobs.
site_id integer Owning site
created_at string When the job was created
started_at string | null When work began
completed_at string | null When the job reached a terminal status (completed, failed, or skipped)
error string | null Only set when status: failed. Deliberately generic ("Analysis failed" / "Crawl failed" / "Crawl cancelled") — raw internal error text is a documented v1 exclusion, not exposed here. Read it to tell a cancelled crawl apart from a failed one.
retry_after_seconds integer | null How long to wait before polling again. null once the job is terminal (completed/failed/skipped) — that's your signal to stop. Always present, so you can branch on it without reading headers. Currently 2 for analyze jobs and 5 for recrawl, but it's server-controlled — read it rather than hardcoding an interval.
result object {} until the job completes. Shape depends on type — see below.

Statuses

Status Meaning
queued Not yet picked up by a worker
processing A worker is actively running the job
completed Finished successfully — see result
failed Finished without succeeding — see error. A crawl that was cancelled also reports failed, with error: "Crawl cancelled"; the status enum has no separate cancelled value.
skipped analyze jobs only. Triggered without force, and the page's content fingerprint hadn't changed since the last analysis, so no new analysis was run. Re-trigger with POST /pages/{pageId}/analyze { "force": true } for a fresh audit.

Poll until status leaves queued/processing, waiting retry_after_seconds (mirrored in the Retry-After response header while the job is in flight) between calls. A malformed job id, an id that doesn't exist, or an id belonging to another organization all resolve identically to 404 not_found — there's no way to distinguish them from the response.

skipped doesn't show up in GET /pages/{pageId}/analysis. That endpoint filters out skipped analysis runs and keeps returning the last real one. The job resource is the only place you learn a trigger was a no-op — poll the job, don't infer from the analysis GET.

result by job type

analyze, once completed:

{ "analysis_id": 7301, "overall_score": 82, "overall_grade": "B+", "score_source": "audit" }

recrawl, once completed:

{ "snapshot_id": 9931 }

snapshot_id: null means the crawl finished but the live page returned an error response (a non-2xx status) — no snapshot was written and the page's content is unchanged. A completed recrawl job is not the same as "content updated" — always check result.snapshot_id.

Errors

Status When
404 not_found No such job — malformed id, wrong prefix, unknown id, or the backing analysis/crawl belongs to a different organization. All look identical (Job not found).

GET /jobs?ids=

Poll up to 50 jobs in a single request. Use this after a batch trigger (POST /sites/{siteId}/analyze) instead of looping GET /jobs/{jobId}.

Scope: sites:read

Query parameters

Parameter Type Description
ids string Required. Comma-separated job ids, 1–50. Whitespace around each id is trimmed, empty segments are dropped, and duplicates collapse to one entry (first position wins) — the 1–50 bound is counted after that.

Request

curl -G https://new.metamonster.ai/api/v1/jobs \
  --data-urlencode "ids=job_an_7301,job_an_7302,job_cr_990" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": [
    {
      "id": "job_an_7301",
      "type": "analyze",
      "status": "completed",
      "page_id": 5001,
      "site_id": 42,
      "created_at": "2026-08-14T10:00:00Z",
      "started_at": "2026-08-14T10:00:01Z",
      "completed_at": "2026-08-14T10:00:18Z",
      "error": null,
      "retry_after_seconds": null,
      "result": { "analysis_id": 7301, "overall_score": 82, "overall_grade": "B+", "score_source": "audit" }
    },
    {
      "id": "job_an_7302",
      "type": "analyze",
      "status": "processing",
      "page_id": 5002,
      "site_id": 42,
      "created_at": "2026-08-14T10:00:00Z",
      "started_at": "2026-08-14T10:00:02Z",
      "completed_at": null,
      "error": null,
      "retry_after_seconds": 2,
      "result": {}
    },
    { "id": "job_cr_990", "status": "not_found" }
  ],
  "meta": { "requested": 3, "found": 2 }
}

Entries are in request order, one per id. A job entry is exactly the GET /jobs/{jobId} shape — same fields, same statuses, same result.

Field Type Description
data[] object Either a full job (see the field table above) or { id, status: "not_found" }
meta.requested integer How many ids were polled — the deduped count, always equal to data.length
meta.found integer How many resolved to a real job; the remaining requested - found entries are not_found

not_found entries

An id that resolves to nothing — malformed, wrong prefix, unknown, or belonging to another organization — comes back as { "id": "…", "status": "not_found" } with id echoed exactly as you sent it. The batch itself is still 200: one bad id never costs you the other 49. not_found is deliberately outside the job status enum, so status === "not_found" is the whole check.

Polling a batch

There's no Retry-After header on this endpoint — a batch usually mixes terminal and in-flight jobs, so a single header can't describe it. Instead:

  1. Sleep the largest non-null retry_after_seconds in data.
  2. Re-poll only the ids whose retry_after_seconds was non-null (null is terminal — stop polling that one).
  3. Stop when every id is terminal.

not_found is terminal too: an id that doesn't resolve now never will.

Errors

Status When
400 invalid_request ids missing, repeated as a multi-value query param, or empty after trimming (ids: required — a single comma-separated list of job ids… / ids: must contain at least one job id), or more than 50 ids after deduping (ids: must contain at most 50 job ids)

Note there's no 404 here — unknown ids are not_found entries inside the 200.