← Blog

Claude API Streaming Response in Android Kotlin

2026-10-15 · 5 min read · SubToAPI Team

Streaming Claude's responses in an Android app means reading a Server-Sent Events (SSE) stream from the API and rendering tokens as they arrive, instead of waiting for the full completion. On Android, this is done with an HTTP client that supports chunked/streaming reads — OkHttp is the standard choice — combined with Kotlin coroutines and Flow to push partial text into your UI layer without blocking the main thread.

The short version: open a POST request to the messages endpoint with "stream": true, read the response body as a stream of event:/data: lines, parse each data: payload as JSON, extract the text delta, and emit it through a Flow that your Compose or View-based UI collects. Below is a complete, working pattern for doing this reliably, including cancellation and error handling, which is where most implementations break.

Why streaming matters on mobile

A non-streamed Claude request on a long generation can take several seconds before any text appears, which feels broken on a phone screen. Streaming fixes the perceived latency problem: the first token can appear in under a second, and the rest renders progressively like a typing effect. It also lets you cancel generation mid-stream (user taps "stop") without wasting the full completion.

The tradeoff is implementation complexity: you need an HTTP client that doesn't buffer the whole response, and you need to parse SSE framing correctly, including partial lines split across network packets.

Setting up the client

Add OkHttp and the SSE extension to your Gradle file:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
}

okhttp-sse gives you EventSource and EventSourceListener, which handle the SSE protocol parsing for you — line buffering, event:/data: splitting, retry hints — so you don't write a manual line reader.

Making the streaming call

If you're calling SubToAPI, requests look identical to the raw Anthropic Messages API shape, since SubToAPI exposes the same request/response contract behind your own sub_live_... key. A raw curl check of the stream format:

curl -N https://api.subtoapi.app/v1/messages \
  -H "Authorization: Bearer $SUBTOAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-sonnet-4-5",
    "max_tokens": 512,
    "stream": true,
    "messages": [{"role": "user", "content": "Explain coroutines in two sentences."}]
  }'

You'll see a sequence of event: content_block_delta lines followed by data: {...} JSON payloads, ending with event: message_stop. Full event semantics are documented at /docs/streaming.

Kotlin: opening the EventSource

import okhttp3.*
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody

fun streamClaude(
    prompt: String,
    apiKey: String,
    client: OkHttpClient,
    onDelta: (String) -> Unit,
    onDone: () -> Unit,
    onError: (Throwable) -> Unit
): EventSource {
    val body = """
        {
          "model": "claude-sonnet-4-5",
          "max_tokens": 1024,
          "stream": true,
          "messages": [{"role": "user", "content": ${prompt.toJsonString()}}]
        }
    """.trimIndent()

    val request = Request.Builder()
        .url("https://api.subtoapi.app/v1/messages")
        .addHeader("Authorization", "Bearer $apiKey")
        .addHeader("Content-Type", "application/json")
        .post(body.toRequestBody("application/json".toMediaType()))
        .build()

    val listener = object : EventSourceListener() {
        override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
            val delta = extractTextDelta(data) ?: return
            onDelta(delta)
        }

        override fun onClosed(eventSource: EventSource) = onDone()

        override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
            onError(t ?: IllegalStateException("Stream failed: ${response?.code}"))
        }
    }

    return EventSources.createFactory(client).newEventSource(request, listener)
}

extractTextDelta just pulls delta.text out of the JSON payload with whatever JSON library you're using (kotlinx.serialization, Moshi, Gson). The full delta shape is in /docs/messages.

Wrapping it in a Flow

Exposing this as a callbackFlow makes it composable with ViewModels and lets collect handle cancellation automatically when the coroutine scope dies:

fun streamClaudeFlow(prompt: String, apiKey: String, client: OkHttpClient): Flow<String> =
    callbackFlow {
        val eventSource = streamClaude(
            prompt = prompt,
            apiKey = apiKey,
            client = client,
            onDelta = { trySend(it) },
            onDone = { close() },
            onError = { close(it) }
        )
        awaitClose { eventSource.cancel() }
    }

In a ViewModel, collect into a StateFlow<String> that accumulates text and drives a Compose Text composable:

class ChatViewModel(private val client: OkHttpClient) : ViewModel() {
    private val _text = MutableStateFlow("")
    val text: StateFlow<String> = _text

    fun send(prompt: String) {
        _text.value = ""
        viewModelScope.launch {
            streamClaudeFlow(prompt, BuildConfig.SUBTOAPI_KEY, client)
                .collect { delta -> _text.value += delta }
        }
    }
}

awaitClose { eventSource.cancel() } is the part that actually stops the HTTP connection and server-side generation when the user navigates away or taps "stop" — without it the stream keeps running and billing keeps accruing tokens on the server side.

Error handling and reconnection

Three failure modes to handle explicitly:

A 401 usually means the key is missing a sub_live_ prefix or was revoked from the dashboard — check /pricing for which plan your key belongs to if you're unsure about seat limits.

Why route through SubToAPI instead of the raw provider

If your Claude access comes through a subscription rather than a pay-per-token developer account, you don't get a standard HTTPS API key to put in your Android app. SubToAPI sits in between: it gives you a sub_live_... key, the same streaming contract shown above, usage metadata per request, and team seats if multiple developers need keys — all from one dashboard. Setup takes a few minutes; see /docs/quickstart to get a key, and /signup for the free trial.

questions

Does OkHttp buffer the whole SSE response before delivering it? No. OkHttp streams the response body as chunks arrive, and okhttp-sse's EventSourceListener.onEvent fires per event, not after the connection closes — as long as you don't call .string() on the raw body yourself.

Can I cancel a stream mid-generation on Android? Yes. Calling eventSource.cancel() (or letting the owning coroutine scope be cancelled, which triggers awaitClose) closes the underlying socket and stops further token generation on the server.

Do I need a different request body for streaming vs. non-streaming calls? Only one field changes: set "stream": true. Everything else — model, messages, max_tokens — stays identical to a standard /docs/messages request.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →