Stream Sume job progress to a browser with SSE and Node polling
Sume has no SSE or WebSocket job stream, so relay it: a small Node endpoint polls the status route and pushes text/event-stream messages to EventSource.

Run a small server of your own that polls GET /v1/jobs/{id}/status and writes each change to the browser as a text/event-stream response, which the built-in EventSource consumes. The Sume Developer API itself offers polling, sync waits and webhooks, not SSE, so the relay belongs in your backend and keeps the API key off the client.
The wire format
MDN describes the stream format: UTF-8 text with the text/event-stream content type, fields such as event: and data:, and a blank line ending each message. Lines that start with a colon are comments, handy as keep-alives. The browser reconnects automatically when the connection drops.
| Field | Purpose | Sent when |
|---|---|---|
event: status | Names the event for addEventListener | Status changed |
data: {...} | JSON of state and flags | Status changed |
: keep-alive | Comment, ignored by the client | Every poll with no change |
Relay
This Node 18 relay stops when data.terminal is true, and it waits next_poll_after_seconds between polls with a 2 second floor.
import http from "node:http";
const API = "https://api.sume.com/v1";
const key = process.env.SUME_API_KEY;
if (!key) throw new Error("SUME_API_KEY is not set");
const headers = { "x-api-key": key };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
http.createServer(async (req, res) => {
const id = new URL(req.url!, "http://x").searchParams.get("job");
if (!id || !/^job_[A-Za-z0-9_]+$/.test(id)) { res.writeHead(400).end(); return; }
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
let closed = false; req.on("close", () => (closed = true));
let last = "";
while (!closed) {
const r = await fetch(`${API}/jobs/${id}/status`, { headers });
if (!r.ok) { res.write(`event: error\ndata: ${JSON.stringify({ status: r.status })}\n\n`); break; }
const { data } = await r.json();
if (data.sume_status !== last) {
last = data.sume_status;
res.write(`event: status\ndata: ${JSON.stringify({ status: data.sume_status, terminal: data.terminal })}\n\n`);
} else res.write(": keep-alive\n\n");
if (data.terminal) break;
await sleep(Math.max(2, data.next_poll_after_seconds ?? 2) * 1000);
}
res.end();
}).listen(8787);Browser side
In the browser: const es = new EventSource('/stream?job=' + id); es.addEventListener('status', (e) => render(JSON.parse(e.data))); and call es.close() on a terminal status, otherwise the browser reconnects and you poll again.
Limits to plan for
Each open stream polls Sume once per interval, so many viewers of one job should share one poller. The job id check above keeps callers from steering the upstream path. Authenticate your own endpoint, because the relay reads with your key.
Sources
Related posts
More in Developers
- One shared key after Sora? Who can read which Sume job
Sume jobs belong to the member whose key created them. If a worker and a web app use different keys, one gets 404 on the other's job. Plan the key layout.
- Shopify rejects file names ending in thumb, icon or large
Shopify file uploads reject names ending in pico, icon, thumb, testing, small, compact, medium, large or grande. A Python rename step for batch outputs.
- Shopify image limits: 20 MB, 25 megapixels vs Sume image outputs
Shopify accepts product images up to 20 MB and 25 megapixels in JPEG, PNG, WEBP, HEIC or GIF. How that lines up with Sume image model sizes and formats.
- Sign MCP requestState for paid render approvals: user, TTL, digest
MCP says requestState is attacker-controlled. For a paid render approval, bind it to the user, an expiry and an argument digest with an HMAC. Python included.
Written by Sume