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.
skippeddoesn't show up inGET /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:
- Sleep the largest non-null
retry_after_secondsindata. - Re-poll only the ids whose
retry_after_secondswas non-null (nullis terminal — stop polling that one). - 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.