emit.run

Jobs API

Create, list, poll, and manage jobs

Jobs API

All job endpoints require an API key. Space-scoped endpoints live under /api/v1/spaces/:spaceId/jobs, and job-specific endpoints live under /api/v1/jobs/:jobId.

Create Job(s)

POST /api/v1/spaces/:spaceId/jobs

Scope: jobs:create

Submit one or more jobs to the space's dispatch queue.

You can send either:

  • a single job object (backward compatible), or
  • an array of job objects for bulk ingestion in a single request.

Request body:

FieldTypeRequiredDefaultDescription
namestringYesJob identifier (e.g., send-email, generate-report)
payloadobjectNonullArbitrary JSON passed to your worker
maxRetriesnumberNo3Max retry attempts on failure (0–25)
timeoutSecondsnumberNo300Timeout per attempt in seconds (1–86400); used for delivery + execution
scheduledForstringNonullISO 8601 timestamp; delays the job until this time (must be in the future)
callbackUrlstringNonullWebhook URL to POST when job reaches a terminal state (completed, dead, killed)
callbackHeadersobjectNonullCustom headers to include in the callback request (string values only)
curl -X POST https://emit.run/api/v1/spaces/$SPACE_ID/jobs \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "name": "process-video",
      "payload": { "url": "s3://bucket/video.mp4", "format": "720p" },
      "maxRetries": 5,
      "timeoutSeconds": 600,
      "callbackUrl": "https://example.com/webhooks/jobs",
      "callbackHeaders": { "Authorization": "Bearer your-secret" }
    },
    {
      "name": "generate-thumbnail",
      "payload": { "url": "s3://bucket/video.mp4", "size": "small" }
    }
  ]'
const res = await fetch(`${API}/spaces/${spaceId}/jobs`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify([
    {
      name: "process-video",
      payload: { url: "s3://bucket/video.mp4", format: "720p" },
      maxRetries: 5,
      timeoutSeconds: 600,
      callbackUrl: "https://example.com/webhooks/jobs",
      callbackHeaders: { Authorization: "Bearer your-secret" },
    },
    {
      name: "generate-thumbnail",
      payload: { url: "s3://bucket/video.mp4", size: "small" },
    },
  ]),
});
const jobs = await res.json();
res = requests.post(
    f"{API}/spaces/{space_id}/jobs",
    headers={"x-api-key": key, "Content-Type": "application/json"},
    json=[
        {
            "name": "process-video",
            "payload": {"url": "s3://bucket/video.mp4", "format": "720p"},
            "maxRetries": 5,
            "timeoutSeconds": 600,
            "callbackUrl": "https://example.com/webhooks/jobs",
            "callbackHeaders": {"Authorization": "Bearer your-secret"},
        },
        {
            "name": "generate-thumbnail",
            "payload": {"url": "s3://bucket/video.mp4", "size": "small"},
        },
    ],
)
jobs = res.json()

Response:

{
  "id": "01JLQX...",
  "spaceId": "01JLQ...",
  "name": "process-video",
  "payload": { "url": "s3://bucket/video.mp4", "format": "720p" },
  "status": "pending",
  "maxRetries": 5,
  "attemptNumber": 0,
  "timeoutSeconds": 600,
  "callbackUrl": "https://example.com/webhooks/jobs",
  "callbackHeaders": { "Authorization": "Bearer your-secret" },
  "deliveredAt": null,
  "startedAt": null,
  "completedAt": null,
  "result": null,
  "createdAt": "2025-02-24T10:30:00.000Z",
  "updatedAt": "2025-02-24T10:30:00.000Z"
}
[
  {
    "id": "01JLQX...",
    "spaceId": "01JLQ...",
    "name": "process-video",
    "payload": { "url": "s3://bucket/video.mp4", "format": "720p" },
    "status": "pending"
  },
  {
    "id": "01JLQY...",
    "spaceId": "01JLQ...",
    "name": "generate-thumbnail",
    "payload": { "url": "s3://bucket/video.mp4", "size": "small" },
    "status": "pending"
  }
]
{ "error": "jobs[0].name is required" }

Scheduled (delayed) jobs

Set scheduledFor to an ISO 8601 timestamp in the future to delay a job. The job enters the scheduled state and is not available for polling until the scheduled time. When the time arrives, it automatically transitions to pending and enters the dispatch queue like any other job.

{
  "name": "send-report",
  "payload": { "reportId": "abc123" },
  "scheduledFor": "2025-03-01T09:00:00.000Z"
}

Scheduled jobs can be killed before they run. If scheduledFor is omitted or in the past, the job is created as pending immediately.


List Jobs

GET /api/v1/spaces/:spaceId/jobs

Scope: jobs:read

Query params:

ParamRequiredDefaultDescription
statusNoFilter: pending, delivered, running, completed, dead, killed
limitNo50Max results (max: 100)
offsetNo0Pagination offset
# List all running jobs
curl "https://emit.run/api/v1/spaces/$SPACE_ID/jobs?status=running&limit=20" \
  -H "x-api-key: $EMIT_KEY"
const res = await fetch(
  `${API}/spaces/${spaceId}/jobs?status=running&limit=20`,
  { headers: { "x-api-key": key } }
);
const jobs = await res.json();
res = requests.get(
    f"{API}/spaces/{space_id}/jobs",
    headers={"x-api-key": key},
    params={"status": "running", "limit": 20},
)
jobs = res.json()

Response:

[
  {
    "id": "01JLQX...",
    "spaceId": "01JLQ...",
    "name": "process-video",
    "payload": { "url": "s3://bucket/video.mp4", "format": "720p" },
    "status": "running",
    "maxRetries": 5,
    "attemptNumber": 1,
    "timeoutSeconds": 600,
    "callbackUrl": "https://example.com/webhooks/jobs",
    "callbackHeaders": { "Authorization": "Bearer your-secret" },
    "deliveredAt": "2025-02-24T10:31:00.000Z",
    "startedAt": "2025-02-24T10:31:05.000Z",
    "completedAt": null,
    "result": null,
    "createdAt": "2025-02-24T10:30:00.000Z",
    "updatedAt": "2025-02-24T10:31:05.000Z"
  }
]

Ordered by creation time (newest first). Returns an empty array if no jobs match.


Poll for Jobs

POST /api/v1/spaces/:spaceId/jobs/poll

Scope: jobs:poll

Claim pending jobs from the dispatch queue. Jobs are atomically dequeued — no two workers will receive the same job.

Request body:

FieldTypeRequiredDefaultDescription
countnumberNo1Number of jobs to claim (max: 10)
typestring | string[]NoPreferred job-type filter. Pass one type or an array of types.
typesstring[]NoAlias for type (array form).
namestring | string[]NoBackward-compatible alias for type.
namesstring[]NoAlias for name (array form).
curl -X POST https://emit.run/api/v1/spaces/$SPACE_ID/jobs/poll \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"count": 5, "type": ["process-video", "generate-thumbnail"]}'
const res = await fetch(`${API}/spaces/${spaceId}/jobs/poll`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({ count: 5, type: ["process-video", "generate-thumbnail"] }),
});
const { jobs } = await res.json();
res = requests.post(
    f"{API}/spaces/{space_id}/jobs/poll",
    headers={"x-api-key": key, "Content-Type": "application/json"},
    json={"count": 5, "type": ["process-video", "generate-thumbnail"]},
)
jobs = res.json()["jobs"]

If you send more than one filter field, precedence is types -> type -> names -> name.

Response:

{
  "jobs": [
    {
      "id": "01JLQX...",
      "name": "process-video",
      "payload": { "url": "s3://bucket/video.mp4", "format": "720p" },
      "timeoutSeconds": 600,
      "maxRetries": 5,
      "attemptNumber": 0,
      "createdAt": "2025-02-24T10:30:00.000Z"
    }
  ]
}

Returns {"jobs":[]} if no jobs are available (or if none match your type/name filter). Your worker should back off and retry.


Get Job

GET /api/v1/jobs/:jobId

Scope: jobs:read

Fetch a job's full details including its event history.

curl https://emit.run/api/v1/jobs/$JOB_ID \
  -H "x-api-key: $EMIT_KEY"
const res = await fetch(`${API}/jobs/${jobId}`, {
  headers: { "x-api-key": key },
});
const job = await res.json();

Response:

{
  "id": "01JLQX...",
  "spaceId": "01JLQ...",
  "name": "process-video",
  "payload": { "url": "s3://bucket/video.mp4", "format": "720p" },
  "status": "completed",
  "maxRetries": 5,
  "attemptNumber": 0,
  "timeoutSeconds": 600,
  "callbackUrl": "https://example.com/webhooks/jobs",
  "callbackHeaders": { "Authorization": "Bearer your-secret" },
  "deliveredAt": "2025-02-24T10:31:00.000Z",
  "startedAt": "2025-02-24T10:31:05.000Z",
  "completedAt": "2025-02-24T10:34:20.000Z",
  "result": { "output_url": "s3://bucket/output.mp4", "duration_ms": 12340 },
  "createdAt": "2025-02-24T10:30:00.000Z",
  "updatedAt": "2025-02-24T10:34:20.000Z",
  "events": [
    {
      "id": "01JLQY...",
      "jobId": "01JLQX...",
      "spaceId": "01JLQ...",
      "type": "created",
      "data": null,
      "createdAt": "2025-02-24T10:30:00.000Z"
    },
    {
      "id": "01JLQZ...",
      "jobId": "01JLQX...",
      "spaceId": "01JLQ...",
      "type": "progress",
      "data": { "percent": 65, "message": "Encoding video" },
      "createdAt": "2025-02-24T10:32:00.000Z"
    },
    {
      "id": "01JLR0...",
      "jobId": "01JLQX...",
      "spaceId": "01JLQ...",
      "type": "completed",
      "data": { "output_url": "s3://bucket/output.mp4", "duration_ms": 12340 },
      "createdAt": "2025-02-24T10:34:20.000Z"
    }
  ]
}
{ "error": "Job not found" }

Get Job Progress

GET /api/v1/jobs/:jobId/progress

Scope: jobs:read:progress or jobs:read

Return the most recent progress update (if any). This endpoint only exposes progress data.

curl https://emit.run/api/v1/jobs/$JOB_ID/progress \
  -H "x-api-key: $EMIT_KEY"
const res = await fetch(`${API}/jobs/${jobId}/progress`, {
  headers: { "x-api-key": key },
});
const progress = await res.json();

Response:

{
  "jobId": "01JLQX...",
  "progress": { "percent": 65, "message": "Encoding video" },
  "timestamp": "2025-02-24T10:32:00.000Z"
}
{ "error": "Job not found" }

If no progress has been reported yet, progress and timestamp are null.


Acknowledge Job

POST /api/v1/jobs/:jobId/ack

Scope: jobs:ack

Tell the system your worker has received the job and is starting work. This moves the job from delivered to running and starts the execution timeout timer. The delivery timeout also uses timeoutSeconds until the job is acked.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/ack \
  -H "x-api-key: $EMIT_KEY"
await fetch(`${API}/jobs/${jobId}/ack`, {
  method: "POST",
  headers: { "x-api-key": key },
});

Response:

{ "success": true }
{ "error": "Job is not in a valid state for ack" }

Worker Stop Signal

If a job is force-stopped via POST /jobs/:id/kill, worker mutation endpoints (progress, checkpoint, event, keepalive) return:

{
  "error": "Job has been killed",
  "code": "JOB_KILLED",
  "jobStatus": "killed"
}

The HTTP status is 409 in that case. Workers should treat this as an immediate stop signal for that job.


Report Progress

POST /api/v1/jobs/:jobId/progress

Scope: jobs:progress

Send a progress update for a running job. The body is strict and must only include:

  • percent — number from 0 to 100
  • message — non-empty user-facing string (e.g. Downloading video, Encoding, Uploading)

Progress events are broadcast to WebSocket subscribers and reset the execution timeout window.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/progress \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"percent": 65, "message": "Encoding video"}'
await fetch(`${API}/jobs/${jobId}/progress`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({ percent: 65, message: "Encoding video" }),
});

Response:

{ "success": true }
{ "error": "Invalid progress payload. Only percent and message are allowed." }

Checkpoint

POST /api/v1/jobs/:jobId/checkpoint

Scope: jobs:event

Store checkpoint data for resumable work. If the job fails and retries, your worker can read past checkpoints to resume from where it left off. Checkpoint events reset the execution timeout window.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/checkpoint \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"checkpoint": "processed_frames", "count": 1500, "last_offset": 45000}'
await fetch(`${API}/jobs/${jobId}/checkpoint`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({
    checkpoint: "processed_frames",
    count: 1500,
    last_offset: 45000,
  }),
});

Response:

{ "success": true }
{ "error": "Job is not running" }

Event

POST /api/v1/jobs/:jobId/event

Scope: jobs:event

Store an arbitrary debug/admin event payload on the job. This endpoint does not change job state; it only records and broadcasts the event. Custom events reset the execution timeout window.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/event \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phase":"encoder","host":"worker-3","note":"switched bitrate ladder"}'
await fetch(`${API}/jobs/${jobId}/event`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({
    phase: "encoder",
    host: "worker-3",
    note: "switched bitrate ladder",
  }),
});

Response:

{ "success": true }
{ "error": "Job is not running" }

Complete Job

POST /api/v1/jobs/:jobId/complete

Scope: jobs:complete

Mark the job as successfully completed. Optionally include a result payload.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/complete \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"result": {"output_url": "s3://bucket/output.mp4", "duration_ms": 12340}}'
await fetch(`${API}/jobs/${jobId}/complete`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({
    result: { output_url: "s3://bucket/output.mp4", duration_ms: 12340 },
  }),
});

Response:

{ "success": true }
{ "error": "Job is not running" }

Fail Job

POST /api/v1/jobs/:jobId/fail

Scope: jobs:fail

Mark the job as failed. If the job has retries remaining, it will be re-queued as pending. Include an error message or object for debugging.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/fail \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"error": "FFmpeg exited with code 1: out of memory"}'
await fetch(`${API}/jobs/${jobId}/fail`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({
    error: "FFmpeg exited with code 1: out of memory",
  }),
});

Response:

{ "success": true }
{ "error": "Job is not running" }

Kill Job

POST /api/v1/jobs/:jobId/kill

Scope: jobs:kill

Force-stop a job and mark it as killed. This is useful for operator intervention, cancellation flows, or control-plane shutdowns.

If the job is already killed, this endpoint is idempotent and returns success.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/kill \
  -H "x-api-key: $EMIT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason": "manual stop"}'
await fetch(`${API}/jobs/${jobId}/kill`, {
  method: "POST",
  headers: { "x-api-key": key, "Content-Type": "application/json" },
  body: JSON.stringify({ reason: "manual stop" }),
});

Response:

{ "success": true }
{ "error": "Job not found", "code": "JOB_NOT_FOUND" }

Keepalive

POST /api/v1/jobs/:jobId/keepalive

Scope: jobs:keepalive

Extend the job's timeout. Call this periodically for long-running jobs to prevent the system from marking them as timed out. Each call resets the timeout window.

curl -X POST https://emit.run/api/v1/jobs/$JOB_ID/keepalive \
  -H "x-api-key: $EMIT_KEY"
// Send keepalive every 60 seconds for a long job
const interval = setInterval(async () => {
  await fetch(`${API}/jobs/${jobId}/keepalive`, {
    method: "POST",
    headers: { "x-api-key": key },
  });
}, 60_000);

try {
  await doLongRunningWork();
  await fetch(`${API}/jobs/${jobId}/complete`, { method: "POST", headers });
} finally {
  clearInterval(interval);
}

Response:

{ "success": true }
{ "error": "Job is not running" }

Callbacks (Webhooks)

Jobs can optionally specify a callbackUrl when created. When the job reaches a terminal statecompleted, dead (failed after all retries), or killed (force-stopped) — the system sends a POST request to that URL.

When callbacks fire

Terminal stateTrigger
completedWorker calls the complete endpoint
deadJob fails and has no retries remaining
killedJob is force-stopped via the kill endpoint

Callbacks do not fire on intermediate failures that will be retried.

Callback payload

The POST body is JSON with this shape:

{
  "jobId": "01JLQX...",
  "spaceId": "01JLQ...",
  "name": "process-video",
  "status": "completed",
  "attemptNumber": 0,
  "result": { "output_url": "s3://bucket/output.mp4" },
  "timestamp": "2025-02-24T10:35:00.000Z"
}

For dead jobs, result contains the final error:

{
  "jobId": "01JLQX...",
  "spaceId": "01JLQ...",
  "name": "process-video",
  "status": "dead",
  "attemptNumber": 3,
  "result": { "error": "FFmpeg exited with code 1: out of memory" },
  "timestamp": "2025-02-24T10:42:00.000Z"
}

For killed jobs, result includes the kill metadata:

{
  "jobId": "01JLQX...",
  "spaceId": "01JLQ...",
  "name": "process-video",
  "status": "killed",
  "attemptNumber": 1,
  "result": { "reason": "manual stop" },
  "timestamp": "2025-02-24T10:36:00.000Z"
}

Request headers

Every callback request includes:

HeaderValue
Content-Typeapplication/json
User-Agentemit.run/1.0

Any headers you set in callbackHeaders at job creation time are merged in, so you can include auth tokens or signing secrets:

{
  "callbackUrl": "https://emit.run/webhooks/jobs",
  "callbackHeaders": {
    "Authorization": "Bearer whsec_abc123"
  }
}

Delivery tracking

Callback delivery is recorded as a job event:

Event typeMeaning
callback_sentRequest succeeded (any HTTP status). Event data includes url and statusCode.
callback_failedRequest could not be sent (network error, DNS failure, etc.). Event data includes url and error.

You can inspect these events via GET /api/v1/jobs/:jobId in the events array.


Scope Quick Reference

EndpointRequired scope
POST /spaces/:id/jobsjobs:create
GET /spaces/:id/jobsjobs:read
POST /spaces/:id/jobs/polljobs:poll
GET /jobs/:idjobs:read
GET /jobs/:id/progressjobs:read:progress or jobs:read
POST /jobs/:id/ackjobs:ack
POST /jobs/:id/progressjobs:progress
POST /jobs/:id/checkpointjobs:event
POST /jobs/:id/eventjobs:event
POST /jobs/:id/completejobs:complete
POST /jobs/:id/failjobs:fail
POST /jobs/:id/killjobs:kill
POST /jobs/:id/keepalivejobs:keepalive

A token with jobs:worker covers all worker scopes: jobs:read, jobs:poll, jobs:ack, jobs:progress, jobs:event, jobs:complete, jobs:fail, jobs:kill, jobs:keepalive, and jobs:read:progress.

On this page