Scala 3 library / sbt plugin for the Klaviyo REST API.
A typed client is generated at build time in your project, from the
official Klaviyo OpenAPI spec at
klaviyo/openapi.
The sbt plugin handles fetching the spec, codegen, scalafmt, and caching;
your project just gets typed Scala sources under src_managed/.
- Transport-agnostic — built on sttp-client 4.
Any sttp backend works:
Identity(synchronous),Future, cats-effectIO, ZIO, pekko-http, etc. The sameKlaviyoClient[F]adapts to your chosen effect type automatically via the backend. - JSON via jsoniter-scala.
- Errors as exceptions — generated methods return
F[A]; failures surface through your effect's natural error channel (throw,Future.failed,IO.raiseError, …) as subtypes ofKlaviyoError. - Three-state fields — every optional field is typed
Tristate.Optional[A],Tristate.Nullable[A], orTristate.Maybe[A], so the distinction between value, explicitnulland omitted is preserved end-to-end. Passing a plain value works thanks to an implicitConversion. - JSON:API discriminators hidden — every
typefield on a resource is injected by the codec, not the public case class. No moretype = AccountEnum.Accountboilerplate at every call site. - Nested types — single-parent
Attributes/Relationships/ inline enums live under the parent's companion (Foo.Attributes,Foo.Status) instead of as standalone flat-named types. - Typed formats —
date/date-time/uri/binarymap tojava.time.LocalDate/java.time.OffsetDateTime/java.net.URI/java.nio.file.Path. - Multipart uploads —
upload_image_from_fileuses sttp's multipart parts; binary fields take aPath. - Arbitrary-object fields — JSON:API "open object" fields
(
template render context,profile.properties,event.event_properties, webhook headers, …) are typed asRawJson— validating-by-default, unchecked-passthrough opt-in. No empty case classes silently dropping data. - Smart retries —
429and503always retried;500/502/504and transport exceptions retried only for idempotent methods (GET/HEAD/OPTIONS/TRACE/PUT/DELETE). Pre-send transport errors (ConnectException, DNS, TLS handshake) retried regardless of method. HonoursRetry-Afterexactly; fails fast when the server asks for longer thanRetryPolicy.maxDelay. - Pagination helpers —
collectAll/foreachPagewalk Klaviyo's cursor-basedlinks.nextfor you. - Typed filters — per-endpoint
<OperationName>Filterbuilders generated from thex-klaviyo-filtersspec extension; no hand-rolledequals(id,"x")strings. Filter values are URL-encoded, double-quoted for strings, bare for booleans / numbers /OffsetDateTime. - API slicing — generate only the operations / categories you use via
klaviyoTagsand/orklaviyoOperationIds. Reachable DTOs are pruned automatically. Useful for projects that touch one slice of Klaviyo's surface (e.g. justProfiles+Events).
Add the plugin to project/plugins.sbt:
addSbtPlugin("com.alexdupre" % "sbt-klaviyo4s" % "x.y.z")Enable it on your project in build.sbt:
lazy val app = (project in file("."))
.enablePlugins(Klaviyo4sPlugin)
.settings(
scalaVersion := "3.4.2"
)Run sbt compile. The plugin downloads the spec to klaviyo-specs/stable.json,
generates the typed client under src_managed/, and your code compiles against
it. Commit klaviyo-specs/stable.json so other developers and CI get a
deterministic build.
import com.alexdupre.klaviyo.* // KlaviyoClient, GeneratedSpecRevision, extensions
import com.alexdupre.klaviyo.models.* // generated DTOs and Filter builders
import sttp.client4.httpurlconnection.HttpURLConnectionBackend
val backend = HttpURLConnectionBackend()
val config = KlaviyoConfig(
auth = KlaviyoAuth.PrivateKey(sys.env("KLAVIYO_KEY")),
revision = GeneratedSpecRevision
)
val client = KlaviyoClient(backend, config)
val accounts = client.accounts.getAccounts()
val campaign = client.campaigns.getCampaigns(
filter = GetCampaignsFilter.archived.equals(false)
)GeneratedSpecRevision is a constant the codegen emits at the package root.
It always matches the spec your project regenerated against, so the runtime
revision header is guaranteed to track the shapes your generated DTOs encode.
| Spec field shape | Generated type | Reachable cases |
|---|---|---|
| required, non-nullable | T |
(just the value) |
| required, nullable | Tristate.Nullable[T] |
Null | Value |
| optional, non-nullable | Tristate.Optional[T] |
Absent | Value |
| optional, nullable | Tristate.Maybe[T] |
Absent | Null | Value |
The companion adds Option lifts (toOptional, toNullable, toMaybeAbsent,
toMaybeNull) and Option-flavoured eliminators (toOption, get,
getOrElse, contains, exists, foreach, fold, map).
| Setting | Type | Default | Purpose |
|---|---|---|---|
klaviyoSpecVersion |
String |
"main" |
Git ref of klaviyo/openapi to fetch. Use a date branch (e.g. "2026-04-15") to pin a Klaviyo revision. |
klaviyoSpecsDir |
File |
<project>/klaviyo-specs |
Where the downloaded spec is cached. Commit stable.json to make builds reproducible. |
klaviyoBasePackage |
String |
"com.alexdupre.klaviyo" |
Root Scala package for generated sources. Override to namespace under your org. |
klaviyoUseScalafmt |
Boolean |
true |
Run scalafmt over emitted sources. Disable for offline / hermetic builds. |
klaviyoTags |
Seq[String] |
Seq.empty |
Restrict generation to listed operation tags (e.g. Seq("Profiles", "Lists")). Empty = no filter. Composes additively with klaviyoOperationIds. |
klaviyoOperationIds |
Seq[String] |
Seq.empty |
Restrict generation to listed operationIds (e.g. Seq("get_lists", "create_event")). Composes with klaviyoTags as a union — useful for pulling a single extra op from a different category. |
klaviyoRefreshSpecs |
task | — | Re-download the spec at klaviyoSpecVersion. Invoked automatically on first build. |
klaviyoGenerate |
task | — | Regenerate sources. Wired into Compile / sourceGenerators; cached on spec hash + plugin version + settings. |
KlaviyoConfig exposes the runtime knobs:
| Field | Type | Default | Purpose |
|---|---|---|---|
auth |
KlaviyoAuth |
required | Currently KlaviyoAuth.PrivateKey; future OAuth-bearer adds a new case. |
revision |
String |
required | Pass GeneratedSpecRevision so the revision header tracks the generated DTOs. |
baseUri |
String |
https://a.klaviyo.com |
Override for stubs / regional endpoints. |
retry |
RetryPolicy |
RetryPolicy.default |
Per-request retry behaviour (see below). |
userAgent |
String |
klaviyo4s/<BuildInfo.version> |
Tag your application on top of this for Klaviyo's traffic correlation. |
readTimeout |
FiniteDuration |
30.seconds |
Per-attempt response wait. Surfaces as KlaviyoError.Transport on idempotent methods (retried per RetryPolicy) or fails fast on writes. Connect timeout is configured on the sttp backend itself. |
RetryPolicy defaults: 5 attempts, 200ms base, 30s cap, full-jitter exp backoff.
Set retryTransient = false to collapse to the conservative
"429 + 503 only" behaviour.
Retries call Sleep[F].sleep(duration). Two givens ship with the library:
Sleep.identitySleepforF = Identity— usesThread.sleep.Sleep.futureSleepforF = Future— non-blocking, schedules on a sharedklaviyo4s-schedulerdaemon thread.
Users on cats-effect / ZIO / pekko-http typically write their own given that
delegates to IO.sleep / ZIO.sleep / system.scheduler.scheduleOnce. The
typeclass is the only extension point — define given Sleep[F] in scope and
your instance shadows the default.
| Module | Scala | Published | Purpose |
|---|---|---|---|
klaviyo4s-core |
3 | yes | Runtime: KlaviyoClient, JSON:API envelope, errors, Tristate, RawJson, retry, pagination. |
klaviyo4s-codegen |
2.12 | yes | Build-time library: OpenAPI parser + scala.meta emitter + spec downloader. |
sbt-klaviyo4s |
2.12 | yes | sbt 1.x plugin wrapping the codegen. |
klaviyo4s-codegen and the plugin run on Scala 2.12 because sbt 1.x is on
Scala 2.12. The codegen emits Scala 3 sources — there's no Scala 3 → 2.12
coupling at runtime.
sbt codegen/runMain com.alexdupre.klaviyo.codegen.Generate --fetch-if-missing
sbt sbtPlugin212/scripted # plugin scripted tests including a full scalafmt path
sbt test # core + codegen + examples
sbt sbtPlugin212/publishLocal && sbt codegen/publishLocal && sbt core/publishLocal
BSD 2-Clause. © 2026 Alex Dupre.