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 | progress events | All other events |
|---|---|---|
jobs:read:progress | Full data field included | data field stripped — type and status only |
jobs:read | Full event | Full 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 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 === "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.
| 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":
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"
}| Type | When | data field | With jobs:read:progress |
|---|---|---|---|
init | Immediately on connect | — (current status only) | Full (no data to strip) |
delivered | Job polled by a worker | — | Full (no data to strip) |
acked | Worker acknowledged; job is now running | — | Full (no data to strip) |
progress | Worker sent a progress update | Worker-defined payload (e.g. { percent: 75 }) | Full — data included |
checkpoint | Worker stored a checkpoint | Worker-defined payload | data stripped |
completed | Job completed successfully | Result payload from worker | data stripped |
retried | Job failed, being re-queued | { error, attemptNumber } | data stripped (error and attempt number hidden) |
dead | Job failed with no retries remaining | { error } | data stripped |
delivery_timeout | Worker polled but never acked in time; reverted to pending | — | Full (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 data — delivered, 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.
| 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 === "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 });
};