
Link-shortening and management client: shorten links with emoji aliases, bulk operations, stats and streaming exports, OAuth PKCE sign-in, typed errors, automatic retries, lazy pagination.
The official Kotlin Multiplatform SDK for the spoo.me link management API. Android and JVM today; the KMP structure keeps iOS and JS additive.
val spoo = SpooClient(apiKey = "spoo_your_api_key")
val link = spoo.links.create {
longUrl = "https://example.com/launch"
alias = "launch" // or emoji: "🚀🔥"
maxClicks = 10_000
}
println(link.shortUrl) // https://spoo.me/launchFlow paginationkotlin.time.Instant, whatever the wire formatdependencies {
implementation("me.spoo:spoo:0.1.0")
}Requires Kotlin 2.4+. On Android the minimum SDK is 21; consumer R8 rules
ship in the artifact. The snippets on this page also use
kotlinx-coroutines (already a transitive dependency) and, where named,
kotlinx.serialization for your own types.
Create an API key from your spoo.me dashboard and pass it explicitly:
val spoo = SpooClient(apiKey = "spoo_...")SpooClient() with no credentials works too: anonymous shortening and the
public endpoints (stats, previews, the emoji set) need no account.
Self-hosting spoo.me, injecting an engine, or tagging your app:
val spoo = SpooClient(SpooConfig(
apiKey = "spoo_...",
baseUrl = "https://links.example.com",
engine = OkHttp.create(), // shared pools, proxies, tests
clientTag = "my-app/1.0", // X-Spoo-Client override
))The client is safe to share across coroutines. close() releases the owned
engine; injected engines stay yours to manage.
val link = spoo.links.create {
longUrl = "https://example.com/launch"
password = "secure@123"
expireAfter = Clock.System.now() + 30.days
}Anonymous creations return a one-time claimToken. Store it and the link
can be claimed into an account later:
spoo.links.claim(listOf(ClaimRequest(urlId = link.id, token = link.claimToken!!)))// Paginated listing with typed filters.
val page = spoo.links.list(ListLinksRequest(
pageSize = 50,
sortBy = SortBy.TOTAL_CLICKS,
status = SettableStatus.ACTIVE,
search = "promo",
))
// Or walk everything lazily.
spoo.links.listPaginated().items().collect { println(it.id) }
// Updates only touch what you set; remove* clears a setting explicitly.
spoo.links.update(link.id) {
longUrl("https://example.com/v2")
removePassword()
}
// Bulk operations report per-item outcomes instead of failing the batch.
val outcome = spoo.links.bulkSetStatus(ids, SettableStatus.INACTIVE)
outcome.results.filterNot { it.ok }.forEach { println("${it.id}: ${it.errorCode}") }val report = spoo.stats.account(AccountStatsRequest(
query = StatsQuery(
groupBy = listOf(Dimension.TIME, Dimension.COUNTRY),
filters = mapOf(FilterDimension.BROWSER to listOf("Chrome")),
),
))
println("${report.summary.totalClicks} clicks")
val perLink = spoo.stats.forLink(link.id)
// Exports stream; filenames from the server are reduced to a bare name
// (no separators or dot-segments), so joining one into a directory cannot
// traverse out of it. Choosing a safe directory remains your job.
val export = spoo.stats.exportLink(link.id, ExportFormat.CSV)
File(downloads, export.filename).writeBytes(export.bytes())Account-wide downloads come from stats.export(); per-link downloads with
per-link filenames come from stats.exportLink(id).
val anon = SpooClient()
val stats = anon.publicLinks.stats("launch")
val locked = anon.publicLinks.stats("locked", password = "hunter@22")
val preview = anon.publicLinks.preview("launch") // never reveals what the redirect refuses
val emoji = anon.emoji.set() // ETag-cached on the clientEvery failure is a SpooException, and coroutine cancellation is always
rethrown untouched. API failures are a sealed hierarchy carrying the
backend's machine-readable code, the offending field, request id and
rate-limit state:
try {
spoo.links.get("gone")
} catch (e: NotFoundException) {
println("no such link")
} catch (e: RateLimitException) {
println("wait ${e.rateLimit.retryAfter}")
} catch (e: ContentBlockedException) {
println("taken down")
} catch (e: AuthenticationException) {
if (e.isPasswordRequired) promptForLinkPassword()
}Transient failures (408, 429, 5xx) retry twice with jittered exponential
backoff capped at 8 seconds, honoring both legal Retry-After forms with a
60 second ceiling: a longer mandated wait surfaces immediately with the
full wait readable on the exception. Requests that could duplicate work on
replay (POST, PATCH) retry only where the server provably did nothing
(429, 503). Default timeout is 30 seconds.
The client half of the connected-apps flow: PKCE, the code exchange, and a self-refreshing session.
val anon = SpooClient()
val pkce = generatePkcePair()
val state = generateState()
val url = anon.oauth.authorizationUrl(
appId = "your_app_id",
state = state,
codeChallenge = pkce.challenge,
redirectUri = "https://your.app/callback",
)
// Open url in a browser (Custom Tabs on Android); the callback carries
// code and state. Verify the echoed state matches BEFORE exchanging the
// code, and reject the flow on a mismatch.
val tokens = anon.oauth.exchangeCode(code, pkce.verifier)
val session = Session(tokens.tokens(), onRefresh = { pair ->
// Persist the rotated pair: the previous refresh token is dead.
})
val spoo = SpooClient(session = session)
val me = spoo.auth.me()Sessions refresh proactively before the access token expires and once more
after an unexpected 401. Refreshes are single-flight across coroutines, and
a dead refresh token surfaces as SessionExpiredException. Token pairs
redact themselves in toString(), so they never leak into logs.
This SDK covers the v1 data plane: shortening (including emoji aliases), link management, bulk operations, claiming, statistics, exports, public link surfaces, the emoji catalogue, identity read, and Sign in with Spoo. Account administration (API key management, profile editing), service endpoints (health, contact), and the legacy v0 API are deliberately out of scope.
| Area | Methods |
|---|---|
| Shorten |
links.create, links.checkAlias
|
| Manage |
links.list, get, getByAddress, update, setStatus, delete, deleteAllOnDomain
|
| Bulk |
bulkDelete, bulkSetStatus, bulkSetExpiry, bulkMoveDomain
|
| Claim | links.claim |
| Stats |
stats.account, stats.forLink
|
| Export |
stats.export, stats.exportLink
|
| Public |
publicLinks.stats, publicLinks.preview
|
| Emoji | emoji.set |
| Identity | auth.me |
| Sign in with Spoo |
oauth.authorizationUrl, exchangeCode, refreshTokens, Session
|
For v1 endpoints the SDK does not cover yet, typed passthroughs reuse the client's auth, retries, timeout and error mapping:
@Serializable data class Whatever(val ok: Boolean)
val value: Whatever = spoo.get("/api/v1/new-endpoint", listOf("k" to "v"))These are a supported pressure valve. If you need one, the surface has a gap worth an issue on this repo.
MIT
The official Kotlin Multiplatform SDK for the spoo.me link management API. Android and JVM today; the KMP structure keeps iOS and JS additive.
val spoo = SpooClient(apiKey = "spoo_your_api_key")
val link = spoo.links.create {
longUrl = "https://example.com/launch"
alias = "launch" // or emoji: "🚀🔥"
maxClicks = 10_000
}
println(link.shortUrl) // https://spoo.me/launchFlow paginationkotlin.time.Instant, whatever the wire formatdependencies {
implementation("me.spoo:spoo:0.1.0")
}Requires Kotlin 2.4+. On Android the minimum SDK is 21; consumer R8 rules
ship in the artifact. The snippets on this page also use
kotlinx-coroutines (already a transitive dependency) and, where named,
kotlinx.serialization for your own types.
Create an API key from your spoo.me dashboard and pass it explicitly:
val spoo = SpooClient(apiKey = "spoo_...")SpooClient() with no credentials works too: anonymous shortening and the
public endpoints (stats, previews, the emoji set) need no account.
Self-hosting spoo.me, injecting an engine, or tagging your app:
val spoo = SpooClient(SpooConfig(
apiKey = "spoo_...",
baseUrl = "https://links.example.com",
engine = OkHttp.create(), // shared pools, proxies, tests
clientTag = "my-app/1.0", // X-Spoo-Client override
))The client is safe to share across coroutines. close() releases the owned
engine; injected engines stay yours to manage.
val link = spoo.links.create {
longUrl = "https://example.com/launch"
password = "secure@123"
expireAfter = Clock.System.now() + 30.days
}Anonymous creations return a one-time claimToken. Store it and the link
can be claimed into an account later:
spoo.links.claim(listOf(ClaimRequest(urlId = link.id, token = link.claimToken!!)))// Paginated listing with typed filters.
val page = spoo.links.list(ListLinksRequest(
pageSize = 50,
sortBy = SortBy.TOTAL_CLICKS,
status = SettableStatus.ACTIVE,
search = "promo",
))
// Or walk everything lazily.
spoo.links.listPaginated().items().collect { println(it.id) }
// Updates only touch what you set; remove* clears a setting explicitly.
spoo.links.update(link.id) {
longUrl("https://example.com/v2")
removePassword()
}
// Bulk operations report per-item outcomes instead of failing the batch.
val outcome = spoo.links.bulkSetStatus(ids, SettableStatus.INACTIVE)
outcome.results.filterNot { it.ok }.forEach { println("${it.id}: ${it.errorCode}") }val report = spoo.stats.account(AccountStatsRequest(
query = StatsQuery(
groupBy = listOf(Dimension.TIME, Dimension.COUNTRY),
filters = mapOf(FilterDimension.BROWSER to listOf("Chrome")),
),
))
println("${report.summary.totalClicks} clicks")
val perLink = spoo.stats.forLink(link.id)
// Exports stream; filenames from the server are reduced to a bare name
// (no separators or dot-segments), so joining one into a directory cannot
// traverse out of it. Choosing a safe directory remains your job.
val export = spoo.stats.exportLink(link.id, ExportFormat.CSV)
File(downloads, export.filename).writeBytes(export.bytes())Account-wide downloads come from stats.export(); per-link downloads with
per-link filenames come from stats.exportLink(id).
val anon = SpooClient()
val stats = anon.publicLinks.stats("launch")
val locked = anon.publicLinks.stats("locked", password = "hunter@22")
val preview = anon.publicLinks.preview("launch") // never reveals what the redirect refuses
val emoji = anon.emoji.set() // ETag-cached on the clientEvery failure is a SpooException, and coroutine cancellation is always
rethrown untouched. API failures are a sealed hierarchy carrying the
backend's machine-readable code, the offending field, request id and
rate-limit state:
try {
spoo.links.get("gone")
} catch (e: NotFoundException) {
println("no such link")
} catch (e: RateLimitException) {
println("wait ${e.rateLimit.retryAfter}")
} catch (e: ContentBlockedException) {
println("taken down")
} catch (e: AuthenticationException) {
if (e.isPasswordRequired) promptForLinkPassword()
}Transient failures (408, 429, 5xx) retry twice with jittered exponential
backoff capped at 8 seconds, honoring both legal Retry-After forms with a
60 second ceiling: a longer mandated wait surfaces immediately with the
full wait readable on the exception. Requests that could duplicate work on
replay (POST, PATCH) retry only where the server provably did nothing
(429, 503). Default timeout is 30 seconds.
The client half of the connected-apps flow: PKCE, the code exchange, and a self-refreshing session.
val anon = SpooClient()
val pkce = generatePkcePair()
val state = generateState()
val url = anon.oauth.authorizationUrl(
appId = "your_app_id",
state = state,
codeChallenge = pkce.challenge,
redirectUri = "https://your.app/callback",
)
// Open url in a browser (Custom Tabs on Android); the callback carries
// code and state. Verify the echoed state matches BEFORE exchanging the
// code, and reject the flow on a mismatch.
val tokens = anon.oauth.exchangeCode(code, pkce.verifier)
val session = Session(tokens.tokens(), onRefresh = { pair ->
// Persist the rotated pair: the previous refresh token is dead.
})
val spoo = SpooClient(session = session)
val me = spoo.auth.me()Sessions refresh proactively before the access token expires and once more
after an unexpected 401. Refreshes are single-flight across coroutines, and
a dead refresh token surfaces as SessionExpiredException. Token pairs
redact themselves in toString(), so they never leak into logs.
This SDK covers the v1 data plane: shortening (including emoji aliases), link management, bulk operations, claiming, statistics, exports, public link surfaces, the emoji catalogue, identity read, and Sign in with Spoo. Account administration (API key management, profile editing), service endpoints (health, contact), and the legacy v0 API are deliberately out of scope.
| Area | Methods |
|---|---|
| Shorten |
links.create, links.checkAlias
|
| Manage |
links.list, get, getByAddress, update, setStatus, delete, deleteAllOnDomain
|
| Bulk |
bulkDelete, bulkSetStatus, bulkSetExpiry, bulkMoveDomain
|
| Claim | links.claim |
| Stats |
stats.account, stats.forLink
|
| Export |
stats.export, stats.exportLink
|
| Public |
publicLinks.stats, publicLinks.preview
|
| Emoji | emoji.set |
| Identity | auth.me |
| Sign in with Spoo |
oauth.authorizationUrl, exchangeCode, refreshTokens, Session
|
For v1 endpoints the SDK does not cover yet, typed passthroughs reuse the client's auth, retries, timeout and error mapping:
@Serializable data class Whatever(val ok: Boolean)
val value: Whatever = spoo.get("/api/v1/new-endpoint", listOf("k" to "v"))These are a supported pressure valve. If you need one, the surface has a gap worth an issue on this repo.
MIT