How to Build and Test an HTTP API Using Tapir in Scala

Build and test an HTTP API in Scala with Tapir: define typed endpoints, wire server logic, generate OpenAPI docs, and write endpoint tests.

INEZA Felin-Michel

INEZA Felin-Michel

22 May 2026

How to Build and Test an HTTP API Using Tapir in Scala

Apidog for Enterprise

On-Premises Deploy

SSO & RBAC

SOC 2 Compliant

Explore Apidog Enterprise

Tapir stands for Typed API descRiptions. It is a Scala library that lets you describe an HTTP endpoint as a plain value: its method, path, inputs, outputs, and error cases all captured in the type system. From that single description Tapir derives the server route, a client, and an OpenAPI document. You write the contract once and the rest follows.

This is what makes Tapir different from writing routes directly in a web framework. A route handler couples the HTTP wiring to the logic. A Tapir endpoint separates them: the description is one value, the business logic is a separate function, and Tapir joins them. This tutorial builds a small task API, wires it to a server, generates docs, and tests it, with real Scala throughout.

What Tapir gives you

Tapir is built on a simple idea with three payoffs.

The first is type safety. An endpoint’s inputs and outputs are typed, so the compiler checks that your handler returns the right shape. A mismatch between what the endpoint promises and what the logic produces is a compile error, not a production bug.

The second is a single source of truth. Because the endpoint is a value, you generate the OpenAPI spec, the server interpreter, and a client from the same description. They cannot drift apart, because there is only one description. This is the same discipline behind API contract testing, enforced by the compiler.

The third is framework independence. The endpoint description does not know whether it runs on Akka HTTP, Pekko, http4s, or Netty. You pick the server interpreter separately, so the description outlives any framework choice.

It is worth being clear about what Tapir is not. It is not a web framework; it does not handle the socket, the thread pool, or the routing engine. It is a description layer that sits on top of a framework you choose. It is also not a code generator in the usual sense. You do not run a tool that writes Scala files you then edit. The endpoint values are the source, and the server, client, and docs are computed from them at compile time. That keeps the description and the running code from ever falling out of sync, which is the recurring failure mode of generator-based workflows where the generated file gets hand-edited and the source diverges.

Setting up the project

Add Tapir to build.sbt. You need the core module, an OpenAPI module, a server interpreter, and a JSON library. This example uses Pekko HTTP, the actively maintained successor to Akka HTTP, with circe for JSON.

val tapirVersion = "1.11.7"

libraryDependencies ++= Seq(
  "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-pekko-http-server" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % tapirVersion,
  "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.11.3"
)

The current Tapir documentation at tapir.softwaremill.com lists every module and the matching versions, since the ecosystem moves quickly.

Defining a typed endpoint

Start with the domain types and their JSON codecs. circe derives the codecs from the case classes.

import io.circe.generic.auto._
import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.generic.auto._

case class Task(id: Int, title: String, done: Boolean)
case class NewTask(title: String)
case class ApiError(message: String)

Now describe the endpoints. Each is a value built by chaining combinators onto the endpoint base. Read the chain as a sentence: this is a GET to /tasks/{id} that takes an integer path input and returns either an ApiError with a 404 or a Task with a 200.

val getTask: PublicEndpoint[Int, ApiError, Task, Any] =
  endpoint.get
    .in("tasks" / path[Int]("id"))
    .errorOut(statusCode(sttp.model.StatusCode.NotFound).and(jsonBody[ApiError]))
    .out(jsonBody[Task])

val listTasks: PublicEndpoint[Unit, Unit, List[Task], Any] =
  endpoint.get
    .in("tasks")
    .out(jsonBody[List[Task]])

val createTask: PublicEndpoint[NewTask, Unit, Task, Any] =
  endpoint.post
    .in("tasks")
    .in(jsonBody[NewTask])
    .out(statusCode(sttp.model.StatusCode.Created).and(jsonBody[Task]))

The four type parameters on PublicEndpoint are input, error output, success output, and required capabilities. Nothing here runs yet. These are pure descriptions, which is exactly why the same values can produce a server, a client, and documentation.

A few combinators carry most of the weight. in adds an input, whether a path segment, a query parameter, a header, or a body. out describes the success response. errorOut describes the failure response, and a single endpoint can model several error cases by combining variants with oneOf. Each combinator narrows the endpoint’s type, so by the time the description is complete, its type signature is a precise statement of the contract. You can read the signature of getTask and know it takes an Int, can fail with an ApiError, and otherwise returns a Task, without reading a line of implementation.

This is the practical heart of Tapir. The endpoint description is documentation that the compiler enforces. A teammate cannot misread the contract, because the contract is a type, and code that violates it does not build.

Adding server logic

A description becomes a working route when you attach logic with serverLogic. The logic is a function returning Either[ErrorType, SuccessType], wrapped in an effect type like Future. A simple in-memory store keeps the example self-contained.

import scala.concurrent.Future
import scala.collection.concurrent.TrieMap
import sttp.tapir.server.ServerEndpoint

val store = TrieMap[Int, Task](
  1 -> Task(1, "Write the Tapir tutorial", done = false)
)

val getTaskServer: ServerEndpoint[Any, Future] =
  getTask.serverLogic { id =>
    Future.successful(
      store.get(id).toRight(ApiError(s"No task with id $id"))
    )
  }

val listTasksServer: ServerEndpoint[Any, Future] =
  listTasks.serverLogic(_ => Future.successful(Right(store.values.toList)))

val createTaskServer: ServerEndpoint[Any, Future] =
  createTask.serverLogic { newTask =>
    val id = store.size + 1
    val task = Task(id, newTask.title, done = false)
    store.put(id, task)
    Future.successful(Right(task))
  }

The compiler enforces the contract here. getTask promises an error of type ApiError, so toRight must supply an ApiError. Return the wrong type and the code does not compile. The HTTP details, the 404, the JSON encoding, are already handled by the description.

Convert the server endpoints to actual routes with the Pekko interpreter and start the server.

import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.actor.typed.ActorSystem
import org.apache.pekko.actor.typed.scaladsl.Behaviors

implicit val system: ActorSystem[Any] = ActorSystem(Behaviors.empty, "task-api")
import system.executionContext

val routes = PekkoHttpServerInterpreter().toRoute(
  List(getTaskServer, listTasksServer, createTaskServer)
)

Http().newServerAt("localhost", 8080).bind(routes)

The API now answers on port 8080. You changed framework concern in one place, the interpreter, without touching a single endpoint description.

Generating OpenAPI documentation

Because the endpoints are values, the OpenAPI document is a function of them. No annotations, no separate spec file to maintain.

import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter
import sttp.apispec.openapi.circe.yaml._

val docs = OpenAPIDocsInterpreter().toOpenAPI(
  List(getTask, listTasks, createTask),
  "Task API",
  "1.0"
)

println(docs.toYaml)

docs.toYaml produces a complete OpenAPI document. Add the tapir-swagger-ui-bundle module and Tapir serves an interactive Swagger UI directly from your server. The spec cannot lie about the API, because both come from the same endpoint values.

That OpenAPI file is also your bridge to other tooling. Import it into Apidog and you get a browsable API reference, a request console for manual checks, and a generated mock server, all without leaving the spec your Scala code already produced. It is a practical way to give frontend teammates something to work against while the Scala service evolves.

There is a workflow worth calling out here. Tapir produces the spec from your Scala source, so the spec is always current. A mocking tool that consumes that spec then gives your frontend a working endpoint before the real server is deployed anywhere. The contract flows in one direction: Scala types define it, OpenAPI carries it, and the mock and the frontend consume it. Nobody hand-writes the contract twice, so nobody can get the two copies out of step. For Scala teams that ship APIs other teams depend on, this is the main reason to keep the OpenAPI export wired into the build.

Testing the API

Tapir endpoints are testable at two levels.

The first level checks the endpoint logic with no HTTP at all, using the stub interpreter. You build a test backend from the server endpoints and send requests through an sttp client. This is fast because nothing binds a port.

import org.scalatest.flatspec.AsyncFlatSpec
import org.scalatest.matchers.should.Matchers
import sttp.client3._
import sttp.client3.testing.SttpBackendStub
import sttp.tapir.server.stub.TapirStubInterpreter
import sttp.client3.circe._
import io.circe.generic.auto._

class TaskApiSpec extends AsyncFlatSpec with Matchers {

  val backend = TapirStubInterpreter(SttpBackendStub.asynchronousFuture)
    .whenServerEndpointRunLogic(getTaskServer)
    .whenServerEndpointRunLogic(createTaskServer)
    .backend()

  "GET /tasks/1" should "return the seeded task" in {
    basicRequest
      .get(uri"http://test.com/tasks/1")
      .response(asJson[Task])
      .send(backend)
      .map { resp =>
        resp.code.code shouldBe 200
        resp.body.map(_.title) shouldBe Right("Write the Tapir tutorial")
      }
  }

  "GET /tasks/999" should "return a 404 with an error body" in {
    basicRequest
      .get(uri"http://test.com/tasks/999")
      .response(asJson[ApiError])
      .send(backend)
      .map { resp => resp.code.code shouldBe 404 }
  }
}

The second level is a full integration test: bind the real server to a port and call it with an HTTP client. Use that sparingly to confirm the wiring, and rely on the stub-based tests for the bulk of your coverage since they run much faster. The split between fast isolated checks and a thin layer of real ones mirrors good practice across automated testing in general.

A useful property falls out of Tapir’s design: because the endpoint description is shared, a test that builds requests from getTask cannot send a request the production server would reject. The contract is enforced on both sides by the same value. To round-trip that contract through manual exploration and a mock server, Download Apidog and import the OpenAPI file Tapir generates.

Frequently asked questions

What is Tapir in Scala?

Tapir, short for Typed API descRiptions, is a Scala library for describing HTTP endpoints as typed values. From one description it derives the server route, a client, and an OpenAPI document, so the contract is defined once and stays consistent across all three.

Do I have to use Akka HTTP with Tapir?

No. Tapir separates the endpoint description from the server interpreter. You can run the same endpoints on Pekko HTTP, http4s, Netty, Vert.x, or others by choosing a different interpreter module. The descriptions do not change.

How does Tapir generate OpenAPI documentation?

Tapir’s OpenAPI module reads your endpoint values and produces a complete OpenAPI document from them. Because the docs and the server both derive from the same descriptions, the documentation cannot drift out of sync with the actual API.

How do I test a Tapir API without starting a server?

Use the Tapir stub interpreter. It builds an sttp test backend from your server endpoints, so you can send requests and assert on responses without binding a port. These tests are fast, and you keep a small number of full integration tests to confirm the real wiring.

Can I use Tapir’s output with other API tools?

Yes. The OpenAPI document Tapir generates is a standard spec. You can import it into tools like Apidog to get an interactive API reference, a request console, and a generated mock server, which is helpful for sharing the API with frontend developers.

Practice API Design-first in Apidog

Discover an easier way to build and use APIs

How to Build and Test an HTTP API Using Tapir in Scala