A Kotlin Multiplatform client for the loops.so API. Targets Android, JVM (server-side), and iOS — distributable to Kotlin/Java consumers via Maven Central and to Swift consumers via Swift Package Manager.
-
LoopsClientbuilt on Ktor (OkHttp on Android, CIO on JVM, NSURLSession/Darwin on iOS). -
Full coverage of the Loops API, organized into resource groups on the client. Suspend API on Kotlin;
CompletableFuturewrappers on JVM;async/awaiton Swift via SKIE. -
testApiKey()lives directly on the client (GET /api-key); everything else is grouped:Group Endpoints client.contactsfind, create, update, delete, suppression status / removal client.contactPropertieslist, create client.eventssend (with idempotency key) client.transactionalsend, listEmails, plus email management (create / get / update / draft / publish) client.listslist mailing lists client.campaignslist, get, create, update client.emailMessagesget, update client.themeslist, get client.componentslist, get client.sendingIpslist dedicated sending IPs client.uploadscreate (presigned URL), complete -
Two construction modes — direct (server-side) and proxy (mobile) — with the security boundary enforced at the type level.
-
Automatic retry on HTTP 429, configurable network timeouts, and opt-in request logging — see Resilience & configuration.
-
All failures surface as a sealed
LoopsException:LoopsException.Api(statusCode, body)— the server returned a non-2xx response.LoopsException.RateLimit(limit, remaining)— HTTP 429, with thex-ratelimit-*headers parsed. Thrown only after retries are exhausted (see Resilience & configuration).LoopsException.Network(cause)— no response (offline, timeout, DNS). Usually retryable.LoopsException.Serialization(cause)— a 2xx body that didn't match the expected model.
No third-party (Ktor, kotlinx.serialization) exceptions are exposed to consumers.
Loops states explicitly:
"Your Loops API key should never be used client side or exposed to your end users."
A mobile app binary is not a safe place for a secret. R8/ProGuard obfuscates code symbols — not string values. Any key compiled into an APK or IPA can be extracted with standard tooling in minutes. The Loops API key is a full-access account credential: it can send email as your brand, read and delete your entire contact list, and run campaigns.
This library enforces the rule at the type level:
LoopsClient.direct(apiKey)— for server-side use only. Has anapiKeyparameter because the key is safe on a trusted server.LoopsClient.proxy(proxyUrl)— for mobile use. Has noapiKeyparameter, so it is structurally impossible to embed a Loops key in a client binary. Your app talks to your own backend; your backend holds the key and forwards to Loops.
Mobile app ──▶ your backend proxy ──key──▶ api.loops.so
settings.gradle.kts:
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}Module build.gradle.kts:
dependencies {
implementation("io.github.retro99:loops-kmp:1.0.0")
}For a KMP module, add it to
commonMain; Gradle resolves the correct Android/iOS variant per target automatically.
In Xcode: File → Add Package Dependencies… and enter:
https://github.com/retro99/loops-kmp
Pick the version, then:
import LoopsSdk(Swift calls suspend functions via the generated completion-handler/async bridge.)
Use this on a trusted backend (Ktor, Spring, etc.) where the API key is safe:
val client = LoopsClient.direct(apiKey = "YOUR_API_KEY")
try {
val result = client.testApiKey()
println("Key valid for team: ${result.teamName}")
} catch (error: LoopsException.Api) {
println("API rejected the request (${error.statusCode}): ${error.body}")
} catch (error: LoopsException.Network) {
println("Could not reach Loops: ${error.message}")
} finally {
client.close()
}Override baseUrl only for Loops staging or EU data-residency endpoints:
val client = LoopsClient.direct(
apiKey = "YOUR_API_KEY",
baseUrl = "https://eu.loops.so/api/v1/",
)Each resource group hangs off the client. A few representative calls:
// Contacts — custom properties are flattened to top-level JSON automatically.
client.contacts.create(
CreateContactRequest(
email = "a@b.com",
firstName = "Ada",
customProperties = mapOf(
"plan" to LoopsValue.of("pro"),
"signupDate" to LoopsValue.ofDateMillis(1_705_486_871_000),
),
),
)
// ...and read them back: unknown top-level keys are collected into customProperties.
val contact = client.contacts.find(ContactIdentifier.ByEmail("a@b.com")).single()
println(contact.customProperties["plan"]) // StringValue(pro)
// Events — eventProperties stay nested; idempotencyKey is optional.
client.events.send(
EventRequest(eventName = "signup", email = "a@b.com"),
idempotencyKey = "evt-123",
)
// Transactional email management (alpha on the Loops side).
val email = client.transactional.createEmail(NameRequest("Welcome"))
client.transactional.ensureEmailDraft(email.id)
client.transactional.publishEmail(email.id)
val emails = client.transactional.listEmails(perPage = 20) // Page<TransactionalEmail>
// Uploads — the SDK only fetches the presigned URL; you PUT the bytes yourself.
val upload = client.uploads.create(CreateUploadRequest("image/png", bytes.size))
// httpClient.put(upload.presignedUrl) { setBody(bytes) } // your own PUT
val asset = client.uploads.complete(upload.emailAssetId)
println(asset.finalUrl)JVM (Java) callers: every suspend method has a generated
*Async()twin returning aCompletableFuture(e.g.client.contacts.createAsync(...)). (JVM target only.) Swift callers: call the same methods withtry awaitthanks to SKIE.
direct and proxy accept three optional configuration objects. Each has a sensible default,
so you only pass what you want to change.
| Config | Default | What it does |
|---|---|---|
RetryConfig |
RetryConfig.DEFAULT (3 retries, 500 ms → 10 s backoff) |
Retries HTTP 429 with exponential backoff, honouring a Retry-After header. RetryConfig.NONE disables it. |
TimeoutConfig |
TimeoutConfig.NONE (engine defaults) |
Request / connect / socket timeouts. TimeoutConfig.DEFAULT applies 30 s / 10 s / 30 s. |
LoggingConfig |
LoggingConfig.none() (off) |
Request/response logging at a chosen LogLevel. Off by default because header/body logs can leak the API key and PII; on the JVM it bridges to SLF4J. |
val client = LoopsClient.direct(
apiKey = "YOUR_API_KEY",
baseUrl = LoopsClient.LOOPS_BASE_URL,
retry = RetryConfig(maxRetries = 5),
timeout = TimeoutConfig.DEFAULT,
logging = LoggingConfig.of(LogLevel.INFO),
)Swift sees the same factory (Kotlin default arguments don't cross the Swift boundary, so an engine-free overload is provided):
let client = LoopsClient.companion.direct(
apiKey: "YOUR_API_KEY",
baseUrl: LoopsClient.companion.LOOPS_BASE_URL,
retry: RetryConfig.companion.DEFAULT,
timeout: TimeoutConfig.companion.DEFAULT,
logging: LoggingConfig.companion.of(level: .info, logger: nil)
)Use this inside an Android or iOS app. Your app talks to your own backend, which holds
the Loops key server-side. The proxyUrl points at your backend — never at app.loops.so.
No app auth (proxy is public or uses cookies/mTLS):
val client = LoopsClient.proxy(proxyUrl = "https://your-backend.com/loops/")With a rotating session token (re-evaluated per request — no client rebuild on refresh):
val client = LoopsClient.proxy(
proxyUrl = "https://your-backend.com/loops/",
auth = ProxyAuth.BearerToken { sessionStore.currentToken() },
)With arbitrary headers:
val client = LoopsClient.proxy(
proxyUrl = "https://your-backend.com/loops/",
auth = ProxyAuth.Headers {
mapOf("X-App-User-Id" to userId, "X-App-Session" to sessionId)
},
)| Type | Description |
|---|---|
ProxyAuth.None |
No extra auth. Default. |
ProxyAuth.BearerToken { token() } |
Adds Authorization: Bearer <token>. The lambda runs on every request — return null to omit the header. |
ProxyAuth.Headers { headers() } |
Adds arbitrary headers. The lambda runs on every request. |
Both lambda types are suspend, so token refresh / async lookups work without extra wiring.
The consumer owns caching; the library just calls the lambda.
// before
val client = LoopsClient(apiKey = "YOUR_API_KEY")
// after
val client = LoopsClient.direct(apiKey = "YOUR_API_KEY")Tag and push — the Release GitHub Action (on a macOS runner) publishes the full
multiplatform artifact to Maven Central, builds the iOS XCFramework, attaches it to a
GitHub Release, and updates Package.swift:
git tag 0.1.1
git push origin 0.1.1Requires these repository secrets: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD,
SIGNING_KEY (ASCII-armored GPG private key), SIGNING_KEY_PASSWORD.
Licensed under the GNU General Public License v3.0.
./gradlew build # compile + run tests for all targets
./gradlew iosSimulatorArm64Test # run common tests on the iOS simulator
./gradlew testAndroidHostTest # run common tests on the Android host
./gradlew assembleLoopsSdkReleaseXCFramework # produces build/XCFrameworks/release/LoopsSdk.xcframework