An unofficial Java SDK for the Kick public API.
Covers OAuth 2.1, every documented REST resource, and webhooks — signature verification, typed event payloads and dispatch.
Full documentation → — getting started · authentication · resources · webhooks · errors · recipes
- Java 21,
java.net.http.HttpClient, Jackson. No other runtime dependencies. - Async only: every call returns a
CompletableFuture. - Framework-agnostic webhooks: the webhook package depends on nothing else in the SDK, so it
drops into Spring, Javalin, Helidon or a bare
HttpServer.
<dependency>
<groupId>io.github.andradenathan</groupId>
<artifactId>kick-sdk-java</artifactId>
<version>0.1.0</version>
</dependency>var kick = KickClient.withAppCredentials("client-id", "client-secret");
kick.categories().list(CategoryQuery.named("slots").withLimit(25))
.thenAccept(page -> page.items().forEach(category -> System.out.println(category.name())))
.join();The client is immutable and thread safe. Build one and share it: it owns a single HttpClient and
a token provider that renews tokens for every resource at once.
For public data, when no user is involved. Tokens are fetched on the first call and renewed automatically before they expire.
var kick = KickClient.builder()
.credentials("client-id", "client-secret")
.build();Send the user to Kick, then exchange the code you get back. Keep state and the PKCE verifier for
the callback.
var oauth = OAuthClient.builder("client-id").clientSecret("client-secret").build();
var request = AuthorizationRequest.of("https://myapp.example/callback",
Scope.USER_READ, Scope.CHAT_WRITE, Scope.EVENTS_SUBSCRIBE);
session.put("state", request.state());
session.put("verifier", request.pkce().codeVerifier());
redirect(oauth.authorizeUrl(request));On the callback, verify state yourself, then:
OAuthTokens tokens = oauth.exchangeCode(code, "https://myapp.example/callback", verifier).join();
var kick = KickClient.builder()
.credentials("client-id", "client-secret")
.userTokens(tokens)
.onTokensRenewed(renewed -> tokenRepository.save(userId, renewed))
.build();Kick rotates the refresh token on every renewal, so the previous one stops working. Persist each
new pair through onTokensRenewed, or a restart loses the ability to renew.
If the redirect URI host is literally 127.0.0.1, call .withLoopbackWorkaround(true) on the
authorization request — Kick's frontend rewrites the first occurrence of that host, and the SDK
inserts the decoy parameter that works around it.
KickClient.builder().tokenProvider(() -> myVault.currentKickToken()).build();| Accessor | Endpoints |
|---|---|
kick.categories() |
list (v2, cursor-paginated), search and get (v1, deprecated) |
kick.users() |
get by id, me(), introspect() |
kick.channels() |
by user id, by slug, me(), update livestream metadata |
kick.channelRewards() |
list, create, update, delete, redemptions, accept, reject |
kick.chat() |
send, delete message |
kick.moderation() |
ban, timeout, unban |
kick.livestreams() |
list (v2, cursor-paginated), for user, stats, listV1 (deprecated) |
kick.kicks() |
leaderboard |
kick.ads() |
start break, status, enroll |
kick.publicKey() |
get, build a verifier from the live key |
kick.events() |
list, subscribe, unsubscribe webhook subscriptions |
kick.drops() |
claims, update claims |
Where Kick publishes both a v1 and a v2 of an endpoint, the unsuffixed method is v2; the v1 method
stays available and is marked @Deprecated, matching how Kick documents it.
Cursor-paginated endpoints return Page<T>:
String cursor = null;
do {
Page<Livestream> page = kick.livestreams()
.list(LivestreamQuery.inCategory(15).withCursor(cursor))
.join();
page.items().forEach(this::index);
cursor = page.nextCursor().orElse(null);
} while (cursor != null);Drops returns its cursor in a different place than the v2 endpoints; both are normalised into the
same Page.
Three pieces, usable separately: a verifier, a parser and a dispatcher.
var dispatcher = WebhookDispatcher.withKickPublicKey()
.on(ChatMessageSent.class, event ->
log.info("{}: {}", event.sender().username(), event.content()))
.on(ChannelFollowed.class, event -> welcome(event.follower()))
.on(ModerationBanned.class, event -> {
if (event.metadata().isPermanent()) {
audit(event.bannedUser());
}
});Then, in whatever serves your webhook endpoint:
// Spring MVC
@PostMapping("/webhooks/kick")
ResponseEntity<Void> receive(@RequestBody byte[] body, HttpServletRequest request) {
dispatcher.handle(WebhookHeaders.from(request::getHeader), body);
return ResponseEntity.ok().build();
}Pass the raw body. Kick signs <message-id>.<timestamp>.<body>, so re-serialising a parsed
payload changes whitespace and key order and the signature stops matching. The API enforces this:
WebhookParser only accepts a VerifiedWebhook, which only WebhookVerifier can produce.
KickEvent is a sealed interface, so a switch over it is exhaustive:
String describe(KickEvent event) {
return switch (event) {
case ChatMessageSent e -> e.sender().username() + " said " + e.content();
case ChannelFollowed e -> e.follower().username() + " followed";
case KicksGifted e -> e.gift().amount() + " kicks from " + e.sender().username();
case UnknownEvent e -> "unmodelled event " + e.eventName();
default -> event.type().toString();
};
}UnknownEvent exists so a new Kick event type cannot break a deployed application.
The verifier uses the public key embedded in the SDK. To use the live one instead:
var verifier = kick.publicKey().verifier().join();
var dispatcher = new WebhookDispatcher(verifier, new WebhookParser());Kick unsubscribes an app whose endpoint keeps failing for over a day, so reconcile subscriptions on startup rather than assuming they persist:
kick.events().subscribe(broadcasterUserId,
KickEventType.CHAT_MESSAGE_SENT,
KickEventType.CHANNEL_FOLLOWED)
.thenAccept(results -> results.stream()
.filter(result -> !result.succeeded())
.forEach(result -> log.warn("{} failed: {}", result.name(), result.error())));Subscribing reports one result per event, so a request can partly succeed.
Failures arrive as a failed future carrying an unchecked exception:
KickException
├── KickApiException HTTP status, Kick's message, the raw body
│ ├── KickAuthException 401 / 403
│ ├── KickNotFoundException 404
│ └── KickRateLimitException 429, exposes retryAfter()
├── KickTransportException connection failure, timeout, unparseable body
└── KickWebhookException bad signature, missing header, malformed payload
GETs are retried on 429 and 5xx with exponential backoff and jitter — three attempts by default,
honouring Retry-After when Kick sends it. Writes are never retried, so a chat message cannot be
posted twice. Configure with .retryPolicy(...), disable with RetryPolicy.none().
Unofficial and not affiliated with Kick. Built against the API as documented in August 2026.