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:
| Endpoint | Minimum scope |
|---|---|
GET /api/v1/realtime/:jobId | jobs:read:progress |
GET /api/v1/realtime/spaces/:spaceId | jobs: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.
| Scope | Events delivered |
|---|---|
jobs:read:progress | init (last known progress, if any), progress, and completed (without data) |
jobs:read | Full event stream with all payloads |
With jobs:read:progress, clients receive progress updates plus a completion signal. Result/error payloads and all other event types are withheld. Use jobs:read if you need full completion payloads, failure details, or kill events.
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 and live progress 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
- Delivers progress updates and a completion signal (no result/error payloads)
- Cannot create, poll, ack, complete, or list jobs in any way
Find it in the dashboard under Tokens → Public 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 === "init" && event.progress) {
renderProgressBar(event.progress.percent);
} else if (event.type === "progress") {
renderProgressBar(event.data.percent); // full progress data included
}
};For failure or kill updates, use webhooks or have your backend send a final status update.
Job Stream
GET /api/v1/realtime/:jobId
Connect to a single job's event stream. You receive an init event immediately on connect — full status with jobs:read, or last known progress (if any) with jobs:read:progress — then live events as they occur.
| Param | Required | Description |
|---|---|---|
token | Yes | API 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":
if (event.progress) {
console.log("Last progress:", event.progress);
} else {
console.log("Current status:", event.status);
}
break;
case "progress":
console.log("Progress:", event.data); // e.g. { percent: 65, message: "Encoding video" }
break;
case "completed":
console.log("Done:", event.data); // full payload only with jobs:read
ws.close();
break;
case "dead":
console.error("Failed:", event.data); // full payload only with jobs:read
ws.close();
break;
case "killed":
console.warn("Stopped:", event.data); // full payload only with jobs:read
ws.close();
break;
case "retried":
console.log("Retrying, attempt:", event.data?.attemptNumber); // jobs:read only
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":
if event.get("progress"):
print("Last progress:", event["progress"])
else:
print("Current status:", event["status"])
case "progress":
print("Progress:", event.get("data"))
case "completed":
print("Done:", event.get("data")) # jobs:read only
break
case "dead":
print("Failed:", event.get("data")) # jobs:read only
break
case "killed":
print("Stopped:", event.get("data")) # jobs:read only
break
case "retried":
print("Retrying, attempt:", event.get("data", {}).get("attemptNumber")) # jobs:read only
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
Full-scope events (jobs:read) share this shape:
{
"jobId": "01JLQX...",
"jobName": "process-video",
"type": "progress",
"status": "running",
"data": { "percent": 65, "message": "Encoding video" },
"timestamp": "2025-02-24T10:32:00.000Z"
}Progress-scope events (jobs:read:progress) include progress data plus completion status:
{
"jobId": "01JLQX...",
"type": "progress",
"data": { "percent": 65, "message": "Encoding video" },
"timestamp": "2025-02-24T10:32:00.000Z"
}| Type | When | data field | jobs:read:progress |
|---|---|---|---|
init | Immediately on connect | — (current status only) | Delivered (includes progress if available) |
progress | Worker sent a progress update | { percent, message } | Delivered |
delivered | Job polled by a worker | — | Not delivered |
acked | Worker acknowledged; job is now running | — | Not delivered |
checkpoint | Worker stored a checkpoint | Worker-defined payload | Not delivered |
event | Worker stored a custom debug/admin event | Worker-defined payload | Not delivered |
completed | Job completed successfully | Result payload from worker | Delivered (without data) |
retried | Job failed, being re-queued | { error, attemptNumber } | Not delivered |
dead | Job failed with no retries remaining | { error } | Not delivered |
killed | Job force-stopped before completion | { reason } | Not delivered |
delivery_timeout | Worker polled but never acked in time; reverted to pending | — | Not delivered |
With jobs:read:progress, only init, progress, and completed are delivered. completed omits data.
Message samples
Sent immediately on connect. With jobs:read, it includes current status; with jobs:read:progress, it includes the last known progress (if any).
{
"jobId": "01JLQX...",
"type": "init",
"status": "running",
"timestamp": "2025-02-24T10:31:05.000Z"
}Sent each time the worker calls the progress endpoint. data always includes { percent, message }.
{
"jobId": "01JLQX...",
"jobName": "process-video",
"type": "progress",
"status": "running",
"data": { "percent": 65, "message": "Encoding video" },
"timestamp": "2025-02-24T10:32:00.000Z"
}Sent when the worker marks the job complete. data contains the result payload with jobs:read; jobs:read:progress clients receive the same event without data.
{
"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. Delivered only with jobs:read.
{
"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 is force-stopped before completion. Delivered only with jobs:read.
{
"jobId": "01JLQX...",
"jobName": "process-video",
"type": "killed",
"status": "killed",
"data": { "reason": "manual stop" },
"timestamp": "2025-02-24T10:36:00.000Z"
}Sent when a job fails but still has retries remaining. data includes the error and attempt number. Delivered only with jobs:read.
{
"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, failures, and kills.
The stream sends a connected event immediately on connect.
| Param | Required | Description |
|---|---|---|
token | Yes | API 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 === "init" && event.progress) {
updateProgressBar(event.progress.percent);
}
if (event.type === "progress") updateProgressBar(event.data.percent);
};Dashboard live feed
Use the space stream with a jobs:read 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 });
};