emit.run

WebSockets

Real-time job and space event streams

WebSockets

Subscribe to real-time events for a single job or an entire space. Events are pushed as JSON messages over a persistent WebSocket connection.

Auth: API key via ?token= query param — required on all endpoints.

Required scopes:

EndpointMinimum scope
GET /api/v1/realtime/:jobIdjobs:read:progress
GET /api/v1/realtime/spaces/:spaceIdjobs:read

Scope-Based Data Filtering

Your token's scope controls what data is included in events. This lets you safely hand tokens to end users without exposing job internals.

Scopeprogress eventsAll other events
jobs:read:progressFull data field includeddata field stripped — type and status only
jobs:readFull eventFull event

With jobs:read:progress, clients always know what happened (event type and status are always present) but the data field is stripped from everything except progress and init. Concretely: result payloads (completed), error details (dead), checkpoint payloads (checkpoint), and retry metadata — including both the error message and the attempt number — (retried) are all hidden. Progress percentage and any custom progress fields are included in full.

Space scoping is enforced: a space-scoped token can only connect to its own space's stream and to jobs within that space. Connecting to a job in a different space returns 403.

Client-Facing Apps: Use the Public Token

Every space is automatically created with a public token — a pre-generated API key with jobs:read:progress scope. This is the recommended default for client-facing use cases: progress bars, status indicators, live updates in your UI.

The public token is safe to embed in frontend code because it:

  • Can only subscribe to individual job streams — not the space-wide feed
  • Includes full progress event data, but strips result payloads and error details from other events
  • Cannot create, poll, ack, complete, or list jobs in any way

Find it in the dashboard under TokensPublic read-only (progress).

// Backend: create a job, return the job ID to your client
const res = await fetch(`${API}/spaces/${spaceId}/jobs`, {
  method: "POST",
  headers: { "x-api-key": PRODUCER_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ name: "generate-report", payload: { reportId: 42 } }),
});
const job = await res.json();

// Frontend: connect using the space public token — no per-request token generation needed
const ws = new WebSocket(
  `wss://emit.run/api/v1/realtime/${job.id}?token=${SPACE_PUBLIC_TOKEN}`
);

ws.onmessage = (e) => {
  const event = JSON.parse(e.data);
  if (event.type === "progress") {
    renderProgressBar(event.data.percent); // full progress data included
  } else if (event.type === "completed") {
    showComplete(); // event.data is stripped — result is not visible to the client
    ws.close();
  } else if (event.type === "dead") {
    showError(); // event.data is stripped — error detail is not visible to the client
    ws.close();
  }
};

Job Stream

GET /api/v1/realtime/:jobId

Connect to a single job's event stream. You receive an init event immediately on connect with the current job state, then live events as they occur.

ParamRequiredDescription
tokenYesAPI key with jobs:read:progress or higher.
const ws = new WebSocket(
  `wss://emit.run/api/v1/realtime/${jobId}?token=${apiKey}`
);

ws.onmessage = (e) => {
  const event = JSON.parse(e.data);

  switch (event.type) {
    case "init":
      console.log("Current status:", event.status);
      break;
    case "progress":
      console.log("Progress:", event.data); // e.g. { percent: 65, step: "encoding" }
      break;
    case "completed":
      console.log("Done:", event.data); // result payload (stripped with limited scopes)
      ws.close();
      break;
    case "dead":
      console.error("Failed:", event.data); // error detail (stripped with limited scopes)
      ws.close();
      break;
    case "retried":
      console.log("Retrying, attempt:", event.data?.attemptNumber);
      break;
  }
};
import asyncio
import json
import websockets

async def watch_job(job_id: str, api_key: str):
    uri = f"wss://emit.run/api/v1/realtime/{job_id}?token={api_key}"

    async with websockets.connect(uri) as ws:
        async for message in ws:
            event = json.loads(message)

            match event["type"]:
                case "init":
                    print("Current status:", event["status"])
                case "progress":
                    print("Progress:", event.get("data"))
                case "completed":
                    print("Done:", event.get("data"))
                    break
                case "dead":
                    print("Failed:", event.get("data"))
                    break
                case "retried":
                    print("Retrying, attempt:", event.get("data", {}).get("attemptNumber"))

asyncio.run(watch_job("01JLQX...", "emit_YOUR_KEY"))
# websocat is a curl-like tool for WebSockets
websocat "wss://emit.run/api/v1/realtime/$JOB_ID?token=$EMIT_KEY"

Event reference

All events share this shape:

{
  "jobId": "01JLQX...",
  "jobName": "process-video",
  "type": "progress",
  "status": "running",
  "data": { "percent": 65, "step": "encoding" },
  "timestamp": "2025-02-24T10:32:00.000Z"
}
TypeWhendata fieldWith jobs:read:progress
initImmediately on connect— (current status only)Full (no data to strip)
deliveredJob polled by a workerFull (no data to strip)
ackedWorker acknowledged; job is now runningFull (no data to strip)
progressWorker sent a progress updateWorker-defined payload (e.g. { percent: 75 })Fulldata included
checkpointWorker stored a checkpointWorker-defined payloaddata stripped
completedJob completed successfullyResult payload from workerdata stripped
retriedJob failed, being re-queued{ error, attemptNumber }data stripped (error and attempt number hidden)
deadJob failed with no retries remaining{ error }data stripped
delivery_timeoutWorker polled but never acked in time; reverted to pendingFull (no data to strip)

With jobs:read:progress, data is present only on progress events (init carries no data field). It is stripped from checkpoint, completed, retried, and dead. Events that never carry datadelivered, acked, delivery_timeout — are unaffected.

Message samples

Sent immediately on connect with the current job state. No data field.

{
  "jobId": "01JLQX...",
  "jobName": "process-video",
  "type": "init",
  "status": "running",
  "timestamp": "2025-02-24T10:31:05.000Z"
}

Sent each time the worker calls the progress endpoint. data is the arbitrary payload the worker sent.

{
  "jobId": "01JLQX...",
  "jobName": "process-video",
  "type": "progress",
  "status": "running",
  "data": { "percent": 65, "step": "encoding", "fps": 30 },
  "timestamp": "2025-02-24T10:32:00.000Z"
}

Sent when the worker marks the job complete. data contains the result payload. Stripped to null with jobs:read:progress.

{
  "jobId": "01JLQX...",
  "jobName": "process-video",
  "type": "completed",
  "status": "completed",
  "data": { "output_url": "s3://bucket/output.mp4", "duration_ms": 12340 },
  "timestamp": "2025-02-24T10:34:20.000Z"
}

Sent when the job fails with no retries remaining. data contains the final error. Stripped to null with jobs:read:progress.

{
  "jobId": "01JLQX...",
  "jobName": "process-video",
  "type": "dead",
  "status": "dead",
  "data": { "error": "FFmpeg exited with code 1: out of memory" },
  "timestamp": "2025-02-24T10:42:00.000Z"
}

Sent when a job fails but still has retries remaining. data includes the error and attempt number. Both are stripped with jobs:read:progress.

{
  "jobId": "01JLQX...",
  "jobName": "process-video",
  "type": "retried",
  "status": "pending",
  "data": { "error": "Connection timeout", "attemptNumber": 1 },
  "timestamp": "2025-02-24T10:33:10.000Z"
}

Space Stream

GET /api/v1/realtime/spaces/:spaceId

Connect to a space-wide stream. You receive events for all jobs in the space: creation, status changes, progress, completions, and failures.

ParamRequiredDescription
tokenYesAPI key with jobs:read or higher. Must be scoped to this space if the key is space-scoped.
const ws = new WebSocket(
  `wss://emit.run/api/v1/realtime/spaces/${spaceId}?token=${apiKey}`
);

ws.onmessage = (e) => {
  const event = JSON.parse(e.data);
  // { jobId, jobName, type, status, data?, timestamp }
  console.log(`[${event.jobName}] ${event.type} → ${event.status}`);
};
async def watch_space(space_id: str, api_key: str):
    uri = f"wss://emit.run/api/v1/realtime/spaces/{space_id}?token={api_key}"

    async with websockets.connect(uri) as ws:
        async for message in ws:
            event = json.loads(message)
            print(f"[{event['jobName']}] {event['type']}{event['status']}")

asyncio.run(watch_space("01JLQX...", "emit_YOUR_KEY"))

The space stream uses the same event shape as the job stream, with the addition of jobName. Requires jobs:read or higher — so events always include full data.


Common Patterns

Submit-and-watch

Create a job on your backend and immediately stream progress back to the user. Use the space public token on the client side so no sensitive credentials are exposed:

// Backend: create job and return ID to client
app.post("/api/start-report", async (req, res) => {
  const job = await createJob({ name: "generate-report", payload: req.body });
  res.json({ jobId: job.id, token: process.env.SPACE_PUBLIC_TOKEN });
});

// Frontend: stream progress using public token
const { jobId, token } = await startReport(params);
const ws = new WebSocket(`wss://emit.run/api/v1/realtime/${jobId}?token=${token}`);

ws.onmessage = (e) => {
  const event = JSON.parse(e.data);
  if (event.type === "progress") updateProgressBar(event.data.percent);
  if (event.type === "completed" || event.type === "dead") ws.close();
};

Dashboard live feed

Use the space stream with a jobs:read:space token to power a real-time operations dashboard:

const ws = new WebSocket(
  `wss://emit.run/api/v1/realtime/spaces/${spaceId}?token=${dashboardKey}`
);

ws.onmessage = (e) => {
  const event = JSON.parse(e.data);
  updateJobRow(event.jobId, { status: event.status, type: event.type });
};

On this page