|
| 1 | +# spoo.me Kotlin SDK |
| 2 | + |
| 3 | +The official Kotlin Multiplatform SDK for the [spoo.me](https://spoo.me) |
| 4 | +link management API. Android and JVM today; the KMP structure keeps iOS and |
| 5 | +JS additive. |
| 6 | + |
| 7 | +```kotlin |
| 8 | +val spoo = SpooClient(apiKey = "spoo_your_api_key") |
| 9 | + |
| 10 | +val link = spoo.links.create { |
| 11 | + longUrl = "https://example.com/launch" |
| 12 | + alias = "launch" // or emoji: "🚀🔥" |
| 13 | + maxClicks = 10_000 |
| 14 | +} |
| 15 | +println(link.shortUrl) // https://spoo.me/launch |
| 16 | +``` |
| 17 | + |
| 18 | +- Coroutines-first: suspend functions everywhere, `Flow` pagination |
| 19 | +- Typed sealed errors, automatic retries, streaming exports |
| 20 | +- Timestamps in and out as `kotlin.time.Instant`, whatever the wire format |
| 21 | +- Anonymous, API key, and Sign in with Spoo authentication |
| 22 | +- Thin tree: Ktor client and kotlinx.serialization, nothing else |
| 23 | + |
| 24 | +## Install |
| 25 | + |
| 26 | +```kotlin |
| 27 | +dependencies { |
| 28 | + implementation("me.spoo:spoo:0.1.0") |
| 29 | +} |
| 30 | +``` |
| 31 | + |
| 32 | +Requires Kotlin 2.4+. On Android the minimum SDK is 21; consumer R8 rules |
| 33 | +ship in the artifact. The snippets on this page also use |
| 34 | +`kotlinx-coroutines` (already a transitive dependency) and, where named, |
| 35 | +`kotlinx.serialization` for your own types. |
| 36 | + |
| 37 | +## Authentication |
| 38 | + |
| 39 | +Create an API key from your [spoo.me dashboard](https://spoo.me) and pass |
| 40 | +it explicitly: |
| 41 | + |
| 42 | +```kotlin |
| 43 | +val spoo = SpooClient(apiKey = "spoo_...") |
| 44 | +``` |
| 45 | + |
| 46 | +`SpooClient()` with no credentials works too: anonymous shortening and the |
| 47 | +public endpoints (stats, previews, the emoji set) need no account. |
| 48 | + |
| 49 | +Self-hosting spoo.me, injecting an engine, or tagging your app: |
| 50 | + |
| 51 | +```kotlin |
| 52 | +val spoo = SpooClient(SpooConfig( |
| 53 | + apiKey = "spoo_...", |
| 54 | + baseUrl = "https://links.example.com", |
| 55 | + engine = OkHttp.create(), // shared pools, proxies, tests |
| 56 | + clientTag = "my-app/1.0", // X-Spoo-Client override |
| 57 | +)) |
| 58 | +``` |
| 59 | + |
| 60 | +The client is safe to share across coroutines. `close()` releases the owned |
| 61 | +engine; injected engines stay yours to manage. |
| 62 | + |
| 63 | +## Shorten links |
| 64 | + |
| 65 | +```kotlin |
| 66 | +val link = spoo.links.create { |
| 67 | + longUrl = "https://example.com/launch" |
| 68 | + password = "secure@123" |
| 69 | + expireAfter = Clock.System.now() + 30.days |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +Anonymous creations return a one-time `claimToken`. Store it and the link |
| 74 | +can be claimed into an account later: |
| 75 | + |
| 76 | +```kotlin |
| 77 | +spoo.links.claim(listOf(ClaimRequest(urlId = link.id, token = link.claimToken!!))) |
| 78 | +``` |
| 79 | + |
| 80 | +## Manage links |
| 81 | + |
| 82 | +```kotlin |
| 83 | +// Paginated listing with typed filters. |
| 84 | +val page = spoo.links.list(ListLinksRequest( |
| 85 | + pageSize = 50, |
| 86 | + sortBy = SortBy.TOTAL_CLICKS, |
| 87 | + status = SettableStatus.ACTIVE, |
| 88 | + search = "promo", |
| 89 | +)) |
| 90 | + |
| 91 | +// Or walk everything lazily. |
| 92 | +spoo.links.listPaginated().items().collect { println(it.id) } |
| 93 | + |
| 94 | +// Updates only touch what you set; remove* clears a setting explicitly. |
| 95 | +spoo.links.update(link.id) { |
| 96 | + longUrl("https://example.com/v2") |
| 97 | + removePassword() |
| 98 | +} |
| 99 | + |
| 100 | +// Bulk operations report per-item outcomes instead of failing the batch. |
| 101 | +val outcome = spoo.links.bulkSetStatus(ids, SettableStatus.INACTIVE) |
| 102 | +outcome.results.filterNot { it.ok }.forEach { println("${it.id}: ${it.errorCode}") } |
| 103 | +``` |
| 104 | + |
| 105 | +## Statistics and exports |
| 106 | + |
| 107 | +```kotlin |
| 108 | +val report = spoo.stats.account(AccountStatsRequest( |
| 109 | + query = StatsQuery( |
| 110 | + groupBy = listOf(Dimension.TIME, Dimension.COUNTRY), |
| 111 | + filters = mapOf(FilterDimension.BROWSER to listOf("Chrome")), |
| 112 | + ), |
| 113 | +)) |
| 114 | +println("${report.summary.totalClicks} clicks") |
| 115 | + |
| 116 | +val perLink = spoo.stats.forLink(link.id) |
| 117 | + |
| 118 | +// Exports stream; filenames from the server are reduced to a bare name |
| 119 | +// (no separators or dot-segments), so joining one into a directory cannot |
| 120 | +// traverse out of it. Choosing a safe directory remains your job. |
| 121 | +val export = spoo.stats.exportLink(link.id, ExportFormat.CSV) |
| 122 | +File(downloads, export.filename).writeBytes(export.bytes()) |
| 123 | +``` |
| 124 | + |
| 125 | +Account-wide downloads come from `stats.export()`; per-link downloads with |
| 126 | +per-link filenames come from `stats.exportLink(id)`. |
| 127 | + |
| 128 | +## Public endpoints |
| 129 | + |
| 130 | +```kotlin |
| 131 | +val anon = SpooClient() |
| 132 | +val stats = anon.publicLinks.stats("launch") |
| 133 | +val locked = anon.publicLinks.stats("locked", password = "hunter@22") |
| 134 | +val preview = anon.publicLinks.preview("launch") // never reveals what the redirect refuses |
| 135 | +val emoji = anon.emoji.set() // ETag-cached on the client |
| 136 | +``` |
| 137 | + |
| 138 | +## Errors |
| 139 | + |
| 140 | +Every failure is a `SpooException`, and coroutine cancellation is always |
| 141 | +rethrown untouched. API failures are a sealed hierarchy carrying the |
| 142 | +backend's machine-readable code, the offending field, request id and |
| 143 | +rate-limit state: |
| 144 | + |
| 145 | +```kotlin |
| 146 | +try { |
| 147 | + spoo.links.get("gone") |
| 148 | +} catch (e: NotFoundException) { |
| 149 | + println("no such link") |
| 150 | +} catch (e: RateLimitException) { |
| 151 | + println("wait ${e.rateLimit.retryAfter}") |
| 152 | +} catch (e: ContentBlockedException) { |
| 153 | + println("taken down") |
| 154 | +} catch (e: AuthenticationException) { |
| 155 | + if (e.isPasswordRequired) promptForLinkPassword() |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +Transient failures (408, 429, 5xx) retry twice with jittered exponential |
| 160 | +backoff capped at 8 seconds, honoring both legal `Retry-After` forms with a |
| 161 | +60 second ceiling: a longer mandated wait surfaces immediately with the |
| 162 | +full wait readable on the exception. Requests that could duplicate work on |
| 163 | +replay (POST, PATCH) retry only where the server provably did nothing |
| 164 | +(429, 503). Default timeout is 30 seconds. |
| 165 | + |
| 166 | +## Sign in with Spoo |
| 167 | + |
| 168 | +The client half of the connected-apps flow: PKCE, the code exchange, and a |
| 169 | +self-refreshing session. |
| 170 | + |
| 171 | +```kotlin |
| 172 | +val anon = SpooClient() |
| 173 | +val pkce = generatePkcePair() |
| 174 | +val state = generateState() |
| 175 | +val url = anon.oauth.authorizationUrl( |
| 176 | + appId = "your_app_id", |
| 177 | + state = state, |
| 178 | + codeChallenge = pkce.challenge, |
| 179 | + redirectUri = "https://your.app/callback", |
| 180 | +) |
| 181 | +// Open url in a browser (Custom Tabs on Android); the callback carries |
| 182 | +// code and state. Verify the echoed state matches BEFORE exchanging the |
| 183 | +// code, and reject the flow on a mismatch. |
| 184 | + |
| 185 | +val tokens = anon.oauth.exchangeCode(code, pkce.verifier) |
| 186 | + |
| 187 | +val session = Session(tokens.tokens(), onRefresh = { pair -> |
| 188 | + // Persist the rotated pair: the previous refresh token is dead. |
| 189 | +}) |
| 190 | +val spoo = SpooClient(session = session) |
| 191 | +val me = spoo.auth.me() |
| 192 | +``` |
| 193 | + |
| 194 | +Sessions refresh proactively before the access token expires and once more |
| 195 | +after an unexpected 401. Refreshes are single-flight across coroutines, and |
| 196 | +a dead refresh token surfaces as `SessionExpiredException`. Token pairs |
| 197 | +redact themselves in `toString()`, so they never leak into logs. |
| 198 | + |
| 199 | +## Scope |
| 200 | + |
| 201 | +This SDK covers the v1 data plane: shortening (including emoji aliases), |
| 202 | +link management, bulk operations, claiming, statistics, exports, public |
| 203 | +link surfaces, the emoji catalogue, identity read, and Sign in with Spoo. |
| 204 | +Account administration (API key management, profile editing), service |
| 205 | +endpoints (health, contact), and the legacy v0 API are deliberately out of |
| 206 | +scope. |
| 207 | + |
| 208 | +| Area | Methods | |
| 209 | +|---|---| |
| 210 | +| Shorten | `links.create`, `links.checkAlias` | |
| 211 | +| Manage | `links.list`, `get`, `getByAddress`, `update`, `setStatus`, `delete`, `deleteAllOnDomain` | |
| 212 | +| Bulk | `bulkDelete`, `bulkSetStatus`, `bulkSetExpiry`, `bulkMoveDomain` | |
| 213 | +| Claim | `links.claim` | |
| 214 | +| Stats | `stats.account`, `stats.forLink` | |
| 215 | +| Export | `stats.export`, `stats.exportLink` | |
| 216 | +| Public | `publicLinks.stats`, `publicLinks.preview` | |
| 217 | +| Emoji | `emoji.set` | |
| 218 | +| Identity | `auth.me` | |
| 219 | +| Sign in with Spoo | `oauth.authorizationUrl`, `exchangeCode`, `refreshTokens`, `Session` | |
| 220 | + |
| 221 | +## Raw requests |
| 222 | + |
| 223 | +For v1 endpoints the SDK does not cover yet, typed passthroughs reuse the |
| 224 | +client's auth, retries, timeout and error mapping: |
| 225 | + |
| 226 | +```kotlin |
| 227 | +@Serializable data class Whatever(val ok: Boolean) |
| 228 | +val value: Whatever = spoo.get("/api/v1/new-endpoint", listOf("k" to "v")) |
| 229 | +``` |
| 230 | + |
| 231 | +These are a supported pressure valve. If you need one, the surface has a |
| 232 | +gap worth an issue on this repo. |
| 233 | + |
| 234 | +## License |
| 235 | + |
| 236 | +MIT |
0 commit comments