HTTP Client with sttp

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) with Scala Toolkit 0.9.2, which brings sttp client 4.0.21, uPickle 4.4.3 and OS-Lib 0.11.8, on JDK 26. Every example was compiled and run with Scala CLI on 2026-10-16. Scala 3.9 LTS and 3.3 LTS run the same code: the Toolkit is built for Scala 3.3, and nothing here uses a feature newer than that. The Toolkit and the libraries are separate from the language, so they follow their own release schedule (see Versions, Compatibility & JDKs).

sttp is the HTTP client of the Scala Toolkit. It has one description of a request that can be sent through different backends: a synchronous one for scripts, a Future-based one for concurrent programs, and backends for effect libraries. The same API is available on the JVM, in Scala.js and in Scala Native. Its Java counterpart is java.net.http.HttpClient; the Groovy section has a small DSL over that same client on HTTP Client with HttpBuilder. The other Toolkit libraries used here are covered on JSON with uPickle, Files & Processes with OS-Lib and Web Servers with Cask. The build side of //> using lines is on Scala CLI; the Java build tools are on Java Build & Tooling.

The setup used on this page

The official Toolkit documentation sends its requests to public hosts. Here every request goes to localhost, to a small Cask server that is started inside the same program and stopped afterwards, so the output below is exactly what you get when you run the code. The server is shown only because the examples need something to talk to; it is explained on Web Servers with Cask. Cask is not part of the Toolkit, so it is added as a separate dependency, and the sttp uPickle module (for typed JSON bodies) is added the same way. The project.scala file keeps the directives of the four files in one place:

//> using scala 3.10.0
//> using toolkit 0.9.2
//> using dep com.lihaoyi::cask:0.11.3
//> using dep com.softwaremill.sttp.client4::upickle:4.0.21

In sbt the same dependencies are:

libraryDependencies ++= Seq(
  "org.scala-lang" %% "toolkit" % "0.9.2",
  "com.lihaoyi" %% "cask" % "0.11.3",
  "com.softwaremill.sttp.client4" %% "upickle" % "4.0.21")

If you only need the client, the single line //> using dep com.softwaremill.sttp.client4::core:4.0.21 (sbt: "com.softwaremill.sttp.client4" %% "core" % "4.0.21") is enough, and the Toolkit then adds only what you ask for. The server, Server.scala, answers the requests made in the rest of the page, on port 8199 of localhost only:

import castor.Context.Simple.global
import cask.util.Logger.Console.globalLogger

object TrailRoutes extends cask.Routes:
  @cask.get("/trails")
  def trails(region: Option[String] = None, tag: Seq[String] = Seq.empty) =
    val all = Map("alps" -> "Ridge Loop", "pyrenees" -> "Lake Path")
    val chosen = region.flatMap(all.get).toList
    ujson.Obj("trails" -> chosen, "tags" -> tag, "origin" -> "server")

  @cask.get("/whoami")
  def whoami(request: cask.Request) =
    val agent = request.headers.get("x-trail-client").flatMap(_.headOption).getOrElse("-")
    val auth = request.headers.get("authorization").flatMap(_.headOption).getOrElse("-")
    s"client=$agent auth=$auth"

  @cask.postJson("/trails")
  def addTrail(name: ujson.Value, km: ujson.Value) =
    ujson.Obj("saved" -> name.str, "km" -> km.num)

  @cask.post("/notes")
  def notes(request: cask.Request) =
    s"received ${request.text().length} chars"

  @cask.postForm("/upload")
  def upload(map: cask.FormFile, gpx: cask.FormFile) =
    s"files=${map.fileName},${gpx.fileName}"

  @cask.websocket("/echo")
  def echo() = cask.WsHandler { channel =>
    cask.WsActor { case cask.Ws.Text(msg) => channel.send(cask.Ws.Text(s"echo: $msg")) }
  }

  initialize()

object TrailServer extends cask.Main:
  val allRoutes = Seq(TrailRoutes)
  override def host = "localhost"
  override def port = 8199
  def start(): io.undertow.Undertow =
    val server = io.undertow.Undertow.builder
      .addHttpListener(port, host)
      .setHandler(defaultHandler)
      .build
    server.start()
    server

TrailServer extends cask.Main, which has its own main, so a directory that contains it and the client programs has several main classes and Scala CLI asks which one to run. The examples therefore run with scala run . --main-class runClient. The server prints a few INFO lines of its own at start and stop; they are left out of the outputs below. Each client program starts the server, does its work in a try block, and calls server.stop() in finally, so the port is free again when the program ends.

Sending a request

The simplest way to send a request is quickRequest, from sttp.client4.quick.*. That import brings the request builders, the uri interpolator, a default synchronous backend, and a send() method that needs no arguments. A request is an immutable value: get, post, put, patch, delete, head and options choose the method and URI, the other methods add to it, and nothing happens on the network until send().

import sttp.client4.quick.*

val response = quickRequest.get(uri"http://localhost:8199/trails?region=alps").send()
println(s"${response.code} ${response.body}")
200 {"trails":["Ridge Loop"],"tags":[],"origin":"server"}

response is a Response[String]: a status code (code, an sttp.model.StatusCode), a body, the response headers, the statusText and the request that produced it. In a quickRequest the body is always a String, whatever the status, and send() returns it directly, without an Either. This is what makes it good for scripts and what makes it too loose for a service: a 404 and a 200 have the same Scala type. See Choosing how the body is read for the strict variant.

If the import is sttp.client4.* only, the implicit part of quick is missing, and send() needs a backend. The compiler says so (this is the real message from Scala 3.10.0):

-- [E134] Type Error: Bad.scala:7:62
7 |  val r = quickRequest.get(uri"http://localhost:8199/trails").send()
  |          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |None of the overloaded alternatives of method send in class Request with types
  | (backend: sttp.client4.SyncBackend): sttp.client4.Response[String]
  | [F[_$2]](backend: sttp.client4.Backend[F]): F[sttp.client4.Response[String]]
  |match arguments ()

Headers and authentication

header(name, value) adds a header, and headers(...) adds several. quickRequest already carries Accept-Encoding. The auth object has basic, basicToken, bearer and digest; auth.bearer(value) sets Authorization: Bearer value. sttp also computes Content-Type and Content-Length when there is a body, unless you set them. The server above echoes two of the headers it received:

import sttp.client4.quick.*

val r = quickRequest.get(uri"http://localhost:8199/whoami")
  .header("X-Trail-Client", "planner/1.0")
  .auth.bearer("demo-credential")
  .send()
println(r.body)
println(r.request.headers.map(_.name).mkString(", "))
client=planner/1.0 auth=Bearer demo-credential
Accept-Encoding, X-Trail-Client, Authorization

A real credential should come from the environment (sys.env("TRAIL_API_KEY"), as the official page does with a GitHub variable) or from a file, never from a literal in the source. The sttp documentation on authentication also describes digest and OAuth2, and the redirect behaviour: the redirect page of the sttp documentation explains when the Authorization header is or is not forwarded to another host.

The uri interpolator and query parameters

uri"..." builds a sttp.model.Uri. Interpolated values are escaped for the part of the URI they land in, so a value never changes the structure of the address. What gets inserted depends on its type: a String or a number is one value, an Option disappears when it is None, a Seq becomes a repeated parameter, and a Map[String, String] expands to several parameters.

import sttp.client4.quick.*
import sttp.model.Uri

val base = "http://localhost:8199"
val region: Option[String] = Some("pyrenees")
val tags = Seq("lake", "easy")
val u: Uri = uri"$base/trails?region=$region&tag=$tags"
println(u)
println(quickRequest.get(u).send().body)
println(uri"$base/trails?region=${Option.empty[String]}")
println(uri"$base/trails/${"lake path"}")
println(uri"$base/search?${Map("q" -> "a&b", "limit" -> "10")}")
http://localhost:8199/trails?region=pyrenees&tag=lake&tag=easy
{"trails":["Lake Path"],"tags":["lake","easy"],"origin":"server"}
http://localhost:8199/trails
http://localhost:8199/trails/lake%20path
http://localhost:8199/search?q=a%26b&limit=10

The third line shows that the whole region parameter vanishes for None. The fourth shows that a space in a path segment becomes %20, and the last that & inside a value becomes %26 while the separators the map generates stay as they are. Building addresses by string concatenation (s"$base/trails?region=$region") loses all of this, which is why the interpolator is preferred even for simple addresses. The type is Uri, not String, so a request cannot be given an unescaped address by mistake.

Request bodies, JSON and uploads

body(...) accepts a String, a byte array, an InputStream, a java.nio.file.Path (the file is streamed) or a Map[String, String] (sent as a form). A text body is sent as UTF-8; set Content-Type yourself when the server cares. For JSON, the Toolkit way is to build a ujson.Value, write it with ujson.write, and read the response with ujson.read; the case-class way is in the next section.

import sttp.client4.quick.*

val json = ujson.Obj("name" -> "Lake Path", "km" -> 7.5)
val r3 = quickRequest.post(uri"http://localhost:8199/trails")
  .header("Content-Type", "application/json")
  .body(ujson.write(json))
  .send()
println(s"${r3.code} ${ujson.read(r3.body)("saved").str}")

val r4 = quickRequest.post(uri"http://localhost:8199/notes").body("Bring water").send()
println(r4.body)
200 Lake Path
received 11 chars

To upload one file, put a java.nio.file.Path in the body; an OS-Lib path converts with toNIO. To send several files at once, use multipartBody with one part per file (multipartFile(name, path)), or multipart(name, value) for a plain text field. sttp writes the boundary and the Content-Type: multipart/form-data header itself.

import sttp.client4.quick.*

val dir = os.temp.dir()
os.write(dir / "map.txt", "map")
os.write(dir / "route.gpx", "<gpx/>")
val r5 = quickRequest.post(uri"http://localhost:8199/upload")
  .multipartBody(
    multipartFile("map", (dir / "map.txt").toNIO),
    multipartFile("gpx", (dir / "route.gpx").toNIO))
  .send()
println(r5.body)
files=map.txt,route.gpx

The server sees the part names map and gpx as the form fields and the file names as the names of the files, because multipartFile uses the file name of the path as the file name of the part. The uploads are described in the Toolkit documentation with a public host and a PNG file; the code is the same.

Choosing how the body is read

The type of the body is chosen with .response(...), and basicRequest is the request with no default response description (quickRequest is basicRequest with .response(asStringAlways)). The common descriptions are:

Description Resulting body

asString

Either[String, String]: Right for a 2xx status, Left with the body otherwise. This is the default of basicRequest.

asStringAlways

String, whatever the status. The default of quickRequest.

asByteArray, asByteArrayAlways

Either[String, Array[Byte]] and Array[Byte].

asJson[T]

Either[ResponseException[String], T]: the body parsed with a JSON library. Needs the sttp module for it (here upickle); asJsonAlways skips the status check.

asWebSocketAlways(f)

The result of f applied to an open WebSocket; see below.

The sttp uPickle module needs an implicit ReadWriter for the type, which derives provides in one word (see JSON with uPickle and Type Class Derivation). The request below uses the synchronous backend explicitly, because basicRequest has no implicit one:

import sttp.client4.*
import sttp.client4.upicklejson.default.*
import upickle.default.ReadWriter

case class Reply(trails: List[String], tags: List[String], origin: String) derives ReadWriter

val syncBackend = DefaultSyncBackend()
val typed = basicRequest
  .get(uri"http://localhost:8199/trails?region=alps")
  .response(asJson[Reply])
  .send(syncBackend)
println(typed.body)

val failed = basicRequest.get(uri"http://localhost:8199/missing").send(syncBackend)
println(failed.body)
syncBackend.close()
Right(Reply(List(Ridge Loop),List(),server))
Left(Error 404: Not Found)

A 404 is not an exception here: it arrives as a Left that holds the server’s body, and a parse failure arrives as a Left too, with a ResponseException that has the text and the cause. That makes the three outcomes (success, an error status, an unreadable body) explicit values, to be handled with the tools of Either & Try. What is not a value is a failure to get a response at all (see Failures and timeouts).

Backends, asynchronous requests and WebSockets

A request description knows nothing about threads. The backend decides how it is sent and what send returns: DefaultSyncBackend() returns the Response itself (it uses the JDK HttpClient on the JVM), DefaultFutureBackend() returns a Future[Response[...]], and the other backends (cats-effect, ZIO, Pekko HTTP, Monix, fs2, and the fetch backend of Scala.js) return their own effect type. A backend owns connections and threads, so it is created once, shared, and closed at the end; the quick import creates one default backend for you, which is why a script does not have to think about it.

import scala.concurrent.duration.*
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import sttp.client4.*

val asyncBackend = DefaultFutureBackend()

val response: Future[Response[String]] = quickRequest
  .get(uri"http://localhost:8199/trails?region=alps")
  .send(asyncBackend)
println(Await.result(response.map(_.code), 5.seconds))
200

Await.result is there only so that a small program does not end before the answer; a real program composes the futures instead (see Futures & Promises).

A WebSocket is a response type too. asWebSocketAlways(f) opens the connection, hands a WebSocket[Future] to f, and closes the connection when the future that f returns completes:

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import sttp.client4.*
import sttp.client4.ws.async.*
import sttp.ws.WebSocket

def useWebSocket(ws: WebSocket[Future]): Future[String] =
  for
    _ <- ws.sendText("Hello")
    text <- ws.receiveText()
    _ <- ws.close()
  yield text

val ws = basicRequest
  .get(uri"ws://localhost:8199/echo")
  .response(asWebSocketAlways(useWebSocket))
  .send(asyncBackend)
println(Await.result(ws.map(_.body), 5.seconds))
echo: Hello

The official page uses a public echo service with a wss address and a for { ... } comprehension with braces; the form here is the braceless one (see For Comprehensions), and the address is the local server’s. The complete program for these two backends is Async.scala, which ends by closing both backends, syncBackend.close() and Await.result(asyncBackend.close(), 5.seconds).

Failures and timeouts

When there is no answer at all (nobody listens on the port, DNS fails, the connection drops), send throws an SttpClientException (the run below prints its ConnectException subclass; ReadException covers failures while reading the response). Timeouts are part of the request: readTimeout(duration) is how long to wait for the response, and the backend options hold the connection timeout (see the timeouts page of the sttp documentation).

import scala.concurrent.duration.*
import sttp.client4.quick.*

try quickRequest.get(uri"http://localhost:1/").readTimeout(2.seconds).send()
catch case e: Exception => println(e.getClass.getName)
sttp.client4.SttpClientException$ConnectException

Source: sttp: Timeouts

The complete client for this section and the previous ones is in Client.scala (runClient) and Async.scala (runAsync). The first prints, in order, everything shown in the earlier sections, and it ends with a case that is easy to miss: the 404 of a missing route is printed as 404 isSuccess=false. The status text is empty, because the JDK HttpClient that the default backend uses does not expose the reason phrase; match on code or isSuccess, never on the text.

Running the examples

scala run . --main-class runClient
scala run . --main-class runAsync

Scala CLI compiles all .scala files of the directory together. The commands are for a directory that holds project.scala, Server.scala, Client.scala and Async.scala. The first run downloads the dependencies from Maven Central into the Coursier cache; the later runs start in about a second. Client.scala holds the request examples of the sections above in one program, with the server started before and stopped in a finally.

Where the books and older tutorials are outdated

  • sttp 3 (com.softwaremill.sttp.client3, package sttp.client3) is the version most tutorials and books use. The Toolkit documentation now uses client 4: the package is sttp.client4, the artifact is com.softwaremill.sttp.client4::core, and HttpURLConnectionBackend() and HttpClientSyncBackend() are replaced by DefaultSyncBackend(); sttp 3 keeps working but is not what the Toolkit documentation shows.

  • The Toolkit pages themselves still show 4.0.0-RC1 and Toolkit 0.7.0 in their dependency boxes. The Toolkit 0.9.2 used here depends on sttp 4.0.21, and 4.0.27 was the newest 4.0 release at the time of writing.

  • In sttp 3 a request was sent with request.send(backend) always; the argument-less send() of quickRequest is a convenience of the quick import only.

  • Older material builds an address with uri"..." from com.softwaremill.sttp._ or, before that, with string concatenation; the interpolator now comes from sttp.client4.quick.* or sttp.client4.*.

  • The Scala 2 form of the examples uses import sttp.client4.quick._ and braces in for and match; the Scala 3 forms are the ones above.