Server-Sent Events (SSE) Streaming
Some APIs stream their response incrementally over a long-lived connection using Server-Sent Events (SSE) instead of returning a single buffered body. Typical examples include chat-completion and other AI APIs that emit tokens as they're generated. APIMatic detects these operations from the API contract and generates an endpoint that returns a typed, async-iterable stream, so consumers can process events as they arrive using their language's natural async iteration loop.
SSE streaming is currently supported in TypeScript and .NET (v4 beta) SDKs. The generated interface follows the same model in both languages but adapts to each language's idioms, as shown in the examples below.
SSE Configuration in OpenAPI
No special extension is required. An operation is treated as a streaming endpoint whenever its success response declares a text/event-stream media type. The schema associated with that media type describes the shape of each event's payload, and the generated SDK decodes every incoming frame into that type.
paths:
/chat/completions:
post:
operationId: createChatCompletion
responses:
'200':
description: OK
content:
text/event-stream:
schema:
$ref: '#/components/schemas/CreateChatCompletionStreamResponse'
Example Usage
For a streaming endpoint, the generated method returns a stream of decoded events of type T, where T is the schema associated with the text/event-stream response. The stream is single-use and lazy: it's consumed with the language's async iteration construct, and each iteration yields the next decoded event as it arrives over the wire.
- TypeScript
- .NET v4 Beta
The endpoint method returns a Promise<ApiResponse<SseStream<T>>>. The result property is the SseStream<T>, a single-use AsyncIterable<T>.
const body: CreateChatCompletionRequest = {
messages: [{ role: 'user', content: 'Hello!' }],
model: 'gpt-5.4',
stream: true,
};
try {
const response = await chatController.createChatCompletion(body);
// Asynchronously iterating over the server-sent events as they arrive.
for await (const event of response.result) {
console.log(event);
}
// Extracting response status code.
console.log(response.statusCode);
// Extracting response headers.
console.log(response.headers);
} catch (error) {
console.log(error);
}
In this example:
- response: The
ApiResponsereturned by the endpoint. Itsresultproperty is theSseStream<T>. - response.result: The
SseStream<T>. It behaves as anAsyncIterable<T>, allowing direct iteration over decoded events usingfor await...of. - event: A single decoded event payload of type
T. - response.statusCode: The HTTP status code returned when the stream was opened.
- response.headers: The HTTP headers returned when the stream was opened.
- Error Handling: Wrap the streaming logic in
try-catchblocks to catch and handleApiError, as well as the SSE-specific errors described below.
Each streaming endpoint generates two variants. The throwing variant returns a Task<IAsyncEnumerable<T>> and throws on a non-success initial status. The result-based variant, suffixed with AsResult, returns a Task<ApiResult<IAsyncEnumerable<T>, RawError>> and never throws on the initial status. Both are iterated with await foreach.
var request = new CreateChatCompletionRequest
{
Messages = [new UserMessage { Content = "Hello!" }],
Model = "gpt-5.4",
Stream = true,
};
// The throwing variant returns the stream directly and throws on a non-success status.
IAsyncEnumerable<CreateChatCompletionStreamResponse> stream =
await client.CreateChatCompletion(request);
// Asynchronously iterating over the server-sent events as they arrive.
await foreach (var chunk in stream)
{
Console.WriteLine(chunk);
}
To inspect the initial HTTP status and headers, or to handle a non-success status without exceptions, use the AsResult variant:
ApiResult<IAsyncEnumerable<CreateChatCompletionStreamResponse>, RawError> result =
await client.CreateChatCompletionAsResult(request);
// StatusCode and Headers reflect the initial HTTP response, when the stream was opened.
Console.WriteLine(result.StatusCode);
Console.WriteLine(result.Headers);
if (result.TryGetResponse(out var stream))
{
await foreach (var chunk in stream)
{
Console.WriteLine(chunk);
}
}
In this example:
- stream: The streamed response, an
IAsyncEnumerable<T>of decoded events, iterated withawait foreach. - chunk: A single decoded event payload of type
T. - result: The
ApiResult<IAsyncEnumerable<T>, RawError>returned by theAsResultvariant. UseTryGetResponse/TryGetError, orMatch, to branch on the outcome. result.StatusCodeandresult.Headers: The HTTP status code and headers returned when the stream was opened.- Error Handling: The throwing variant throws on a non-success status; the
AsResultvariant surfaces it as a typedRawError. Enumeration can also throw the SSE-specific errors described below.
Accessing Event Metadata
Access to raw SSE frame metadata is currently a TypeScript capability. In .NET, the stream yields decoded events directly, and streams that mix multiple event shapes are modeled as a oneOf union type.
SSE frames can carry metadata in addition to the payload: an id, an event name, and a retry reconnection hint. Iterating the stream directly yields only the decoded payloads; accessing this metadata is shown below.
- TypeScript
Iterate the stream via withMetadata(), which yields full SseEvent<T> objects.
const response = await chatController.createChatCompletion(body);
// Iterate full events, including `id`, `event` and `retry` metadata.
for await (const event of response.result.withMetadata()) {
console.log(event.data); // The decoded payload of type `T`.
console.log(event.id); // The `id:` field value in effect for this event, if any.
console.log(event.event); // The `event:` field value, if any.
console.log(event.retry); // The `retry:` reconnection delay in milliseconds, if any.
}
In this example:
- event.data: The decoded event payload of type
T. - event.event: The
event:field value, if any. - event.id: The
id:field value in effect for this event, if any. - event.retry: The
retry:reconnection delay, in milliseconds, if any.
Closing the Stream
The stream is backed by a live HTTP connection. It's single-use and lazy, so the connection is held only while it's being consumed. Stopping consumption releases the connection.
- TypeScript
- .NET v4 Beta
Breaking out of the iteration loop, or calling close() explicitly, aborts the underlying request.
const response = await chatController.createChatCompletion(body);
for await (const event of response.result) {
console.log(event);
if (shouldStop(event)) {
response.result.close(); // Aborts the connection and stops iteration.
break;
}
}
Breaking out of the await foreach loop disposes the enumerator, which aborts the underlying connection. Passing a CancellationToken cancels it cooperatively. A stream that's never enumerated never opens a connection to release.
using var cts = new CancellationTokenSource();
var stream = await client.CreateChatCompletion(request, cts.Token);
await foreach (var chunk in stream.WithCancellation(cts.Token))
{
Console.WriteLine(chunk);
if (ShouldStop(chunk))
{
break; // Disposes the enumerator and aborts the connection.
}
}
Read Timeout Configuration
To guard against a server that opens a stream but then stalls indefinitely, streaming SDKs expose a read-timeout client configuration option. It bounds the maximum idle window allowed between two consecutive frames, and never the caller's own processing time. If no frame arrives within that window, the stream fails with a timeout error. The default is 60 seconds.
- TypeScript
- .NET v4 Beta
The streamReadTimeout option is a number of milliseconds (default 60000).
const client = new Client({
// Fail if the server stalls for more than 30 seconds between frames.
streamReadTimeout: 30000,
});
The StreamReadTimeout option is a TimeSpan? (default 60 seconds). Set it to null to wait indefinitely.
var client = new Client(httpClient, new ClientOptions
{
// Fail if the server stalls for more than 30 seconds between frames.
StreamReadTimeout = TimeSpan.FromSeconds(30),
});
Error Handling
In addition to the usual API error, streaming endpoints can surface two SSE-specific errors while the stream is being consumed:
- A timeout error, thrown when the server stalls between frames for longer than the configured read timeout. It exposes the idle window that elapsed without a frame arriving.
- A decode error, thrown when a frame's payload can't be decoded into the expected type
T. It exposes the raw frame payload that failed, along with the underlying cause.
- TypeScript
- .NET v4 Beta
The errors are SseTimeoutError (with idleTimeoutMs) and SseDecodeError (with rawFrame and cause). Both extend the SseError base type.
import { SseDecodeError, SseTimeoutError } from 'your-sdk';
try {
const response = await chatController.createChatCompletion(body);
for await (const event of response.result) {
console.log(event);
}
} catch (error) {
if (error instanceof SseTimeoutError) {
// The server stalled longer than `streamReadTimeout`.
console.log(error.idleTimeoutMs);
} else if (error instanceof SseDecodeError) {
// A frame could not be decoded into the expected type.
console.log(error.rawFrame, error.cause);
} else {
console.log(error);
}
}
The errors are SseTimeoutException (with IdleTimeout) and SseDeserializationException (with RawFrame, and the underlying InnerException). Both extend the SseException base type. A non-success initial status throws by default; use the AsResult variant to receive a typed RawError instead.
using YourSdk.Core.Exceptions;
try
{
var stream = await client.CreateChatCompletion(request);
await foreach (var chunk in stream)
{
Console.WriteLine(chunk);
}
}
catch (SseTimeoutException ex)
{
// The server stalled longer than StreamReadTimeout.
Console.WriteLine(ex.IdleTimeout);
}
catch (SseDeserializationException ex)
{
// A frame could not be deserialized into the expected type.
Console.WriteLine(ex.RawFrame);
Console.WriteLine(ex.InnerException);
}
Benefits
- Typed Events: Every frame is decoded into the response schema type, so consumers work with typed objects rather than raw text.
- Familiar Interface: The stream is a standard async sequence, consumed with the language's natural async iteration loop.
- OpenAPI-Driven: Automatically applied to any operation with a
text/event-streamresponse, with no extra configuration required. - Safe by Default: A configurable read timeout prevents stalled connections from hanging indefinitely, and the connection is released as soon as iteration stops.