Streaming results
The same cursor over a held connection, for a client that cannot receive a webhook.
Add Accept: text/event-stream to GET /batches/{id}/results and the connection stays open, pushing each entry as it lands. It is the same cursor, the same entries and the same order - only the transport differs, so code written against the JSON path reads this one.
curl -N "https://api.easydata.win/v1/batches/afec9131-080b-4b96-b3bb-726249e4bbf8/results" \
-H "X-API-Key: $API_KEY" \
-H "Accept: text/event-stream"id: eyJzIjoxfQ
event: result
data: {"item_index":0,"input":"https://linkedin.com/in/satyanadella","status":"succeeded","credits_used":1,"data":{"full_name":"Satya Nadella"},"created_at":"2026-09-01T10:01:09Z"}
: keepalive
id: eyJzIjoyfQ
event: complete
data: {"batch_id":"afec9131-080b-4b96-b3bb-726249e4bbf8","status":"completed","cursor":"eyJzIjoyfQ"}When to use it, and when not to #
If you run a server with a public URL, use webhooks instead. They survive a restart, hold no connection open and are already signed. Streaming is for the client that has nowhere to deliver to: an agent or MCP server on a laptop, a CLI, an edge function, a browser. Those could only poll, and an hour-long batch at the recommended interval is hundreds of requests against a limit that counts them.
It does not make results arrive sooner. We produce entries at the speed LinkedIn answers and this reads the same rows a poller would. What it removes is the request-per-poll cost and the floor your own polling interval put under latency.
Events #
| Event | Carries | Then what |
|---|---|---|
| result | One entry, exactly as the JSON cursor returns it. | Keep reading. There may be many. |
| complete | batch_id, the terminal status, and the final cursor. | The batch is finished and you have all of it. We close the connection. |
| timeout | The same fields plus a reason. | A stream may stay open 30 minutes. Reconnect with the cursor it gave you; nothing is lost. |
| error | type, message, cursor, requestId. | We could not read the next page. Reconnect with that cursor rather than starting over. |
Lines beginning with a colon are keepalive comments, sent about every 25 seconds so proxies do not close an idle connection. Any SSE client ignores them.
Resuming is free #
The event id IS the cursor. EventSource resends the last id it saw as Last-Event-ID when it reconnects, so a dropped connection picks up exactly where it stopped - no duplicates, no gaps, nothing to write. That is the same guarantee the cursor already makes, using a mechanism the protocol already has.
// Reconnects and resumes on its own. There is no retry code to write.
const es = new EventSource(url);
es.addEventListener('result', (e) => save(JSON.parse(e.data)));
es.addEventListener('complete', () => es.close());
es.addEventListener('timeout', () => {
// Nothing to do: EventSource reconnects and sends Last-Event-ID for you.
});EventSource cannot set headers, so a browser cannot send X-API-Key. Stream from your own server, or from any client that can set headers - which is every one of the cases this exists for.
A stream needs the read scope, like every other read. A missing or unknown batch is still a normal JSON 404: we read once before opening the stream, because past that point the status is 200 and an error could only be reported inside the events.
In the official clients #
Every client has this as one call - ed.stream(batch_id) in Python and TypeScript, ed.Stream(ctx, batchID, nil) in Go - and each handles the reconnect itself, exactly, because the event id is the cursor. The reconnect bound is the only knob. Use it wherever you would have used the results cursor and cannot receive a webhook.
for entry in ed.stream(batch.batch_id):
if entry.ok:
save(entry.data)