Async jobs
Preview Most mutations on managed.dev aren’t
instantaneous — installing a plugin, cloning an environment, running a deploy. Rather
than block the request, the API returns a job: a 202 Accepted, a Location
header, and a tracking object you watch to completion. This async-native model is what
lets CLI, Terraform, and your own automation all wait for the same work correctly.
What a non-instant mutation returns
Section titled “What a non-instant mutation returns”A mutation that can’t complete synchronously responds with 202 Accepted, a Location
header pointing at the job, and the job in the body:
HTTP/1.1 202 AcceptedLocation: /v1/jobs/job_01J9…ETag: "w/qd-1"Content-Type: application/json{ "data": { "id": "job_01J9…", "type": "component.install", "status": "queued", "progress": 0, "created_at": "2026-06-23T18:04:11.412Z", "resource": { "type": "component", "kind": "plugin", "slug": "wordpress-seo", "site_id": "site_01J7…", "env_id": "env_01J8…" }, "result": null, "error": null, "links": { "self": "/v1/jobs/job_01J9…", "stream": "/v1/jobs/job_01J9…/stream" } }, "request_id": "req_01J9…"}The job envelope
Section titled “The job envelope”Every job, from every endpoint, has the same shape:
| Field | Description |
|---|---|
id |
The job id (job_01J9…). Quote it to support; use it to poll or stream. |
type |
What’s running, e.g. component.install, environment.clone, deployment.run. |
status |
queued → running → succeeded | failed | canceled. |
progress |
0–100 where the operation reports it; advisory, not every job is granular. |
created_at |
When the job was accepted. |
resource |
The thing being acted on — its type, id, and any context (site_id, env_id, …). |
result |
Populated on succeeded with the outcome (the new resource, output, etc.). null until then. |
error |
Populated on failed with the error object. null otherwise. |
links.self |
The job’s own URL — GET it to poll. |
links.stream |
The SSE endpoint for a live tail. |
A terminal status (succeeded, failed, canceled) is final — the job stops
changing once it reaches one.
Three ways to consume a job
Section titled “Three ways to consume a job”Pick the path that fits your environment. All three watch the same job.
1. The 202 body
Section titled “1. The 202 body”For fire-and-forget work, the job in the 202 body may be all you need — you have the
id for later, and you can move on. Come back and GET it whenever you want the
outcome.
2. SSE live tail
Section titled “2. SSE live tail”For a CLI, a UI, or anything that wants real-time progress, stream the job over server-sent events. Events arrive as the status and progress change, including streamed command output where the job produces it:
curl -N https://api.managed.dev/v1/jobs/job_01J9…/stream \ -H "Authorization: Bearer mfk_live_…" \ -H "Accept: text/event-stream" \ -H "Forge-Version: 2026-06-23"event: statusdata: {"id":"job_01J9…","status":"running","progress":40}
event: logdata: {"line":"Installing wordpress-seo (latest)…"}
event: statusdata: {"id":"job_01J9…","status":"succeeded","progress":100}The mf jobs watch command and the dashboard both ride this stream.
3. ETag long-poll
Section titled “3. ETag long-poll”For SSE-hostile environments — Terraform providers, CI runners behind proxies that
buffer streams — GET the job with If-None-Match. The job returns an ETag; send it
back and you get 304 Not Modified until something changes, then the new state:
curl -i https://api.managed.dev/v1/jobs/job_01J9… \ -H "Authorization: Bearer mfk_live_…" \ -H 'If-None-Match: "w/qd-1"' \ -H "Forge-Version: 2026-06-23"A 304 means “nothing new — keep waiting”; a 200 carries the updated job and a fresh
ETag to poll against next. This is how the Terraform provider
waits for a job without holding a stream open.
A worked example — install a plugin and tail it
Section titled “A worked example — install a plugin and tail it”curl -i -X POST \ https://api.managed.dev/v1/sites/site_01J7…/environments/env_01J8…/components \ -H "Authorization: Bearer mfk_live_…" \ -H "Forge-Version: 2026-06-23" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "kind": "plugin", "slug": "wordpress-seo", "version": "latest", "activate": true }'# → 202 Accepted, Location: /v1/jobs/job_01J9…curl -N https://api.managed.dev/v1/jobs/job_01J9…/stream \ -H "Authorization: Bearer mfk_live_…" \ -H "Accept: text/event-stream" \ -H "Forge-Version: 2026-06-23"# → status running → log lines → status succeededconst job = await mf.components.install("site_01J7…", "env_01J8…", { kind: "plugin", slug: "wordpress-seo", version: "latest", activate: true,});
// the SDK follows the job for you — SSE or poll, transparentlyconst done = await mf.jobs.wait(job.id);if (done.status === "failed") throw done.error;console.log("installed:", done.result);mf components install \ --site site_01J7… --env env_01J8… \ --kind plugin --slug wordpress-seo --activate# streams the job to completion, exits non-zero if it failsListing and finding jobs
Section titled “Listing and finding jobs”GET /v1/jobs lists jobs for the principal, filterable and cursor-paginated:
curl "https://api.managed.dev/v1/jobs?status=failed&resource_type=deployment&limit=20" \ -H "Authorization: Bearer mfk_live_…" \ -H "Forge-Version: 2026-06-23"Reading jobs requires the jobs:read scope. See the
jobs API resource for the full parameter set.