New in 2.2: Validators, JSONPath and signatures See what's new →

Streaming APIs in OpenAPI 3.2: itemSchema, SSE and NDJSON, tested

Share

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:

Terminal
$ 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:

Terminal
$ 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:

FieldApplies toUse it when
schemaThe whole body, treated as an array of itemsThe client reads the complete response before using it, such as an export of at most 100 rows
itemSchemaEach item on its own, as it arrivesThe 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.yaml
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:

  • data is always a string. When your events carry JSON, the schema still sees a string. To describe the JSON inside, use contentMediaType: application/json with contentSchema, as above. The spec points out that contentSchemaisn't checked by default, so a validator that ignores it will pass a data line holding invalid JSON.
  • Comments and unknown fields are dropped before validation. The line : connected, streaming events 1..3 above 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 each data: 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:

sse-check.mjs
// 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");
Output (Node 24, 25 October 2026)
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 valid

We 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:

ndjson-check.mjs
// 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");
Without intervalMs: everything at +190 ms
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 items
With intervalMs=300: one line every 300 ms
status 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 items

Both 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: no on 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() or await 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 itemSchema as it arrives, including the JSON inside SSE data.
  • 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-ID resumes after that event instead of starting again.
  • Content-Type is right. A stream served as application/json sends 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.