Streaming APIs in OpenAPI 3.2: itemSchema, SSE and NDJSON, tested
More APIs stream their responses now than a few years ago. LLM APIs send tokens as they're generated, log and metrics APIs tail live data, and export endpoints send rows as the database returns them. For most of OpenAPI's history you couldn't describe any of that properly. You wrote type: string for the whole body and explained the rest in the description.
OpenAPI 3.2, released in September 2025, fixed that with one new field, itemSchema. Tool support has taken a while to follow. Swagger's open-source tools added 3.2 support in April, and more tools have joined since. In this post we describe two real streaming endpoints with it, then test them. We check each item as it arrives, and we check that the items really do arrive one at a time.
What a stream looks like on the wire
Our API has a few streaming endpoints for practice. Here are two of the most common formats. First, Server-Sent Events (text/event-stream), the format most LLM APIs use for token streaming:
$ curl -N -i "https://api.testingapis.com/api/stream/sse?count=3&interval=200"
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
x-accel-buffering: no
transfer-encoding: chunked
: connected, streaming events 1..3
retry: 3000
id: 1
event: message
data: {"id":1,"message":"Event 1 of 3","timestamp":"2026-10-25T11:13:35.307Z"}
id: 2
event: message
data: {"id":2,"message":"Event 2 of 3","timestamp":"2026-10-25T11:13:35.508Z"}
id: 3
event: message
data: {"id":3,"message":"Event 3 of 3","timestamp":"2026-10-25T11:13:35.708Z"}
event: done
data: {"total":3}Then NDJSON (application/x-ndjson), one complete JSON value per line, which is common for exports and logs:
$ curl -N "https://api.testingapis.com/api/stream/ndjson/3"
{"id":1,"name":"Alpha-001","value":3.14,"even":false}
{"id":2,"name":"Bravo-002","value":6.28,"even":true}
{"id":3,"name":"Charlie-003","value":9.42,"even":false}Neither body is a single JSON document. You can't put the whole response through JSON.parse, and before 3.2 a schema couldn't describe either one.
itemSchema: a schema for each item
OpenAPI 3.2 calls these sequential media types: a body that is a series of the same structure repeated, with no header or footer around it. The spec names application/jsonl, application/x-ndjson, application/json-seq, text/event-stream and multipart/mixed as examples.
For these you now have two ways to write the schema:
| Field | Applies to | Use it when |
|---|---|---|
schema | The whole body, treated as an array of items | The client reads the complete response before using it, such as an export of at most 100 rows |
itemSchema | Each item on its own, as it arrives | The client handles items one at a time, such as tokens, log lines or ticks |
The spec says itemSchema"MUST be applied to each item in the stream independently". That means a validator can check item 1 before item 2 has been sent, and it doesn't have to hold the whole stream in memory. You can use both fields on the same media type.
Here are both endpoints described in 3.2:
openapi: 3.2.0
info:
title: Streaming examples
version: 1.0.0
servers:
- url: https://api.testingapis.com
paths:
/api/stream/ndjson/{n}:
get:
parameters:
- { name: n, in: path, required: true, schema: { type: integer, minimum: 1, maximum: 1000 } }
- { name: intervalMs, in: query, schema: { type: integer, minimum: 0, maximum: 5000, default: 0 } }
responses:
"200":
description: One JSON object per line, sent as each is ready.
content:
application/x-ndjson:
itemSchema:
$ref: "#/components/schemas/Item"
/api/stream/sse:
get:
parameters:
- { name: count, in: query, schema: { type: integer } }
- { name: interval, in: query, schema: { type: integer, description: Milliseconds between events } }
responses:
"200":
description: A message event per item, then one done event.
content:
text/event-stream:
itemSchema:
type: object
required: [data]
properties:
event: { type: string, enum: [message, done] }
id: { type: string }
retry: { type: integer, minimum: 0 }
data:
type: string
contentMediaType: application/json
contentSchema:
$ref: "#/components/schemas/Tick"
components:
schemas:
Item:
type: object
required: [id, name, value, even]
properties:
id: { type: integer }
name: { type: string }
value: { type: number }
even: { type: boolean }
Tick:
type: object
properties:
id: { type: integer }
message: { type: string }
timestamp: { type: string, format: date-time }
total: { type: integer }How SSE maps onto a schema
Each SSE event becomes an object with the fields data, event, id and retry. The spec gives a generic schema for it. Three details in it are easy to get wrong:
datais always a string. When your events carry JSON, the schema still sees a string. To describe the JSON inside, usecontentMediaType: application/jsonwithcontentSchema, as above. The spec points out thatcontentSchemaisn't checked by default, so a validator that ignores it will pass adataline holding invalid JSON.- Comments and unknown fields are dropped before validation. The line
: connected, streaming events 1..3above is a comment. It never reaches the schema. - Several
data:lines make one value.They're joined with newlines into a single string. A client that treats eachdata:line as its own event breaks the first time a server splits a long payload across lines.
One more detail, and it comes from the HTML spec rather than OpenAPI. A block with no data field isn't an event at all. Our stream starts with retry: 3000on its own. A browser's EventSource reads the reconnect delay from it and fires nothing. A test that counts blocks separated by blank lines would find one event too many.
Testing an SSE stream
Here's a test that reads the stream with fetch, parses events following those rules, and checks each one when it arrives. It prints the time since the request started, so you can see the events come in one by one:
// Read an SSE stream as it arrives, parse each event the way the HTML spec says,
// and check every event against what our OpenAPI description promises.
const url = "https://api.testingapis.com/api/stream/sse?count=4&interval=500";
const started = Date.now();
const res = await fetch(url, { headers: { accept: "text/event-stream" } });
console.log("status", res.status, res.headers.get("content-type"));
const problems = [];
let buffer = "";
let event = {};
const decoder = new TextDecoder();
function dispatch() {
if (!("data" in event)) { event = {}; return; } // a block with no data is not an event
const ms = Date.now() - started;
const type = event.event ?? "message";
let body;
try { body = JSON.parse(event.data); } catch { problems.push(`${type}: data is not JSON`); }
if (type === "message" && body && (typeof body.id !== "number" || typeof body.message !== "string"))
problems.push(`message ${event.id}: missing id or message`);
console.log(`+${String(ms).padStart(4)}ms event=${type.padEnd(7)} id=${event.id ?? "-"} data=${event.data}`);
event = {};
}
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
if (line === "") { dispatch(); continue; }
if (line.startsWith(":")) continue; // comment
const i = line.indexOf(":");
const field = i < 0 ? line : line.slice(0, i);
const value = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
if (field === "data") event.data = event.data === undefined ? value : event.data + "\n" + value;
else if (field === "retry") { if (/^\d+$/.test(value)) event.retry = Number(value); }
else if (field === "event" || field === "id") event[field] = value;
}
}
console.log(problems.length ? problems : "all events valid");status 200 text/event-stream
+ 284ms event=message id=1 data={"id":1,"message":"Event 1 of 4","timestamp":"2026-10-25T11:14:30.941Z"}
+ 792ms event=message id=2 data={"id":2,"message":"Event 2 of 4","timestamp":"2026-10-25T11:14:31.441Z"}
+1307ms event=message id=3 data={"id":3,"message":"Event 3 of 4","timestamp":"2026-10-25T11:14:31.942Z"}
+1777ms event=message id=4 data={"id":4,"message":"Event 4 of 4","timestamp":"2026-10-25T11:14:32.442Z"}
+1777ms event=done id=- data={"total":4}
all events validWe asked for four events 500 ms apart, and they arrived about 500 ms apart. That's the thing worth asserting in a streaming test. If all four had arrived at +1777 ms, every item would still be valid. The response just wouldn't be streaming.
Valid items, but not streamed
Saying a response is a stream doesn't make it arrive as one. The NDJSON endpoint shows this. Without a delay it writes all five lines at once, and the client gets them in the same instant:
// Read NDJSON line by line and record when each item arrived.
const started = Date.now();
const res = await fetch("https://api.testingapis.com/api/stream/ndjson/5?intervalMs=300");
console.log("status", res.status, res.headers.get("content-type"));
let buffer = "", n = 0;
const decoder = new TextDecoder();
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines.filter(Boolean)) {
const item = JSON.parse(line); // one bad line fails one item, not the whole response
n++;
console.log(`+${String(Date.now() - started).padStart(4)}ms item ${item.id}: ${item.name}`);
}
}
console.log(n, "items");status 200 application/x-ndjson
+ 190ms item 1: Alpha-001
+ 190ms item 2: Bravo-002
+ 190ms item 3: Charlie-003
+ 191ms item 4: Delta-004
+ 191ms item 5: Echo-005
5 itemsstatus 200 application/x-ndjson
+ 263ms item 1: Alpha-001
+ 553ms item 2: Bravo-002
+ 855ms item 3: Charlie-003
+1153ms item 4: Delta-004
+1459ms item 5: Echo-005
5 itemsBoth responses have the same content type and the same five valid items. Only the timing shows the difference. In a real system the cause is usually not the API. It's something in between that buffers the body before passing it on:
- Reverse proxies. nginx buffers proxied responses by default. Our API sends
X-Accel-Buffering: noon its streams to switch that off for each response. - Compression. A gzip layer may wait until it has enough bytes to compress, and a three-token chunk is never enough.
- Serverless platforms and API gateways. Some return a response only once the function has finished, unless you turn streaming on explicitly.
- The client.
await res.json()orawait res.text()waits for the whole body. So does Swagger UI, which shows a streamed response only after it has ended.
So run the timing check against the deployed URL, through the same CDN and proxies your users go through. Against localhost it will pass even when production buffers.
What to check in any streaming endpoint
- Every item matches
itemSchemaas it arrives, including the JSON inside SSEdata. - Items arrive spread out over time when the server produces them over time.
- The stream ends cleanly: a final event like
done, or the connection closes and the last line is complete. - Errors partway through arrive as a typed event, not a dropped connection the client can't explain.
- For SSE, a reconnect with
Last-Event-IDresumes after that event instead of starting again. Content-Typeis right. A stream served asapplication/jsonsends clients to the wrong parser.
You can try all of these against the endpoints on our streaming reference page, which also has chunked transfer and a drip endpoint that sends bytes slowly. For the wider picture of how streams, WebSockets, polling and webhooks compare, see the async APIs track.