How to Add a Feedback Board to an Android App with Kotlin
Last updated
This guide covers the Kotlin SDK 1.0.1 build from Maven Central. Later releases, tagged 1.1.0, 1.1.1, 1.2.0 and 2.1.0, exist as git tags on the SDK repository but are not on Maven Central yet, so com.getfeedbackkit:feedbackkit resolves to 1.0.1. The changelog sums up what 1.0 contains and everything since 1.0.1. This page is the walkthrough for the registry build: what it does today, and how to ship a board on it, filters, theming and previews included.
By the end of this guide your Android app has a screen where users read the feature requests other people have filed, open one, vote, and file a new one from a form, without leaving the app. You write none of the list, none of the form and none of the vote handling: those are Jetpack Compose composables that ship in the SDK. What you write is one Gradle line, one configuration block with a user id, and the navigation glue that decides where the screen lives.
What works in 1.0.1, and what does not
Reading the board, filtering it, opening a request and filing one work, and vote counts show correctly. Measured against the FeedbackKit API on 18 October 2026, these do not:
- A vote is saved, but the button rolls back. The server records the vote, and 1.0.1 cannot read its reply, so the button returns to its old count and
onVoteChangeis not called. The vote shows once the list reloads. - Removing a vote fails.
unvoteputs the user id on the query string, and the server reads it from the request body. A vote stays once cast. - Comments do not load, because 1.0.1 expects a field in the comment reply that the server does not send. The detail screen has no field for writing one either.
- The form's email does not reach FeedbackKit. The SDK sends it as
email, and the server readsuserEmail. - A vote cannot ask for status emails.
votes.votetakes no email, and itsnotifyOnStatusChangeflag goes out under a key the server does not read. users.registerandevents.trackfail. Neither sends a field the server requires, andusers.getCurrentUser()calls a route the server does not have. Requests from Kotlin 1.0.1 carry no MRR.
What the SDK gives you
The Android SDK is Kotlin, coroutines and Jetpack Compose. The pieces you compose with are FeedbackList, FeedbackDetailView, SubmitFeedbackView, FeedbackCard, VoteButton, StatusBadge and CategoryBadge, plus a FeedbackKitProvider that pushes a theme down the composition. Underneath them sit suspend-function APIs on FeedbackKit.shared: feedback, votes, comments, users and events.
Requirements are minSdk 24, Java 17, and Compose with Material 3; the library is built against Compose BOM 2024.01.00. It brings OkHttp, kotlinx.serialization, kotlinx.coroutines and DataStore with it.
1. Add the dependency
Make sure mavenCentral() is among your repositories, then add one line to your app module's build.gradle.kts:
dependencies {
implementation("com.getfeedbackkit:feedbackkit:1.0.1")
}
The Maven group is com.getfeedbackkit, while the Kotlin packages you import are under com.swiftlydeveloped.feedbackkit. There is no manifest entry to add: the library declares the INTERNET permission in its own manifest, and the manifest merger folds it into your app.
Pin 1.0.1, never 1.0.0. Both went out on 9 February, and 1.0.0's default production address has no FeedbackKit server behind it. 1.0.1 corrects it.
2. Configure once, with a user id
Configuration is a single call with a DSL block. Do it in your Application class rather than an Activity: a later call returns the instance the earlier one created, so a key changed later will not take effect.
import android.app.Application
import com.swiftlydeveloped.feedbackkit.FeedbackKit
import com.swiftlydeveloped.feedbackkit.configure
import java.util.UUID
class TrailmateApp : Application() {
override fun onCreate() {
super.onCreate()
val prefs = getSharedPreferences("trailmate", MODE_PRIVATE)
val feedbackUserId = prefs.getString("feedback_user_id", null)
?: "install-${UUID.randomUUID()}".also {
prefs.edit().putString("feedback_user_id", it).apply()
}
FeedbackKit.configure(this) {
apiKey = BuildConfig.FEEDBACKKIT_API_KEY
userId = feedbackUserId
debug = BuildConfig.DEBUG
}
}
}
Register the class with android:name=".TrailmateApp" on the manifest's <application> element. Note the second import: configure with a trailing lambda is an extension function on the companion, so it needs importing alongside the class. The block also takes environment, which starts at Environment.PRODUCTION, the FeedbackKit server your projects live on. Environment.LOCAL (the Android emulator) and LOCAL_DEVICE are for running a FeedbackKit server yourself. timeout is in milliseconds and defaults to 30 seconds. Everything the SDK sends carries an X-API-Key header holding your key; there is no login step.
The user id is the part that is easy to miss. Every request and every vote belongs to one, 1.0.1 does not create one, and without it filing fails on the server while votes.vote() throws a FeedbackKitError.ValidationError before sending anything. If your app has accounts, use the account id. When someone signs in after launch, switch with FeedbackKit.shared.setUserIdAndPersist(account.id), a suspend function that also stores the id in DataStore; loadUserIdFromStorage() reads it back, and logout() clears it. Votes cast under an earlier id stay with that id. Keep debug to debug builds: it prints each request's address and each response body, and both carry user ids.
Where the key should live
An API key compiled into an APK is not secret: anyone can unzip and decompile a shipped app and read it. The FeedbackKit SDK key is a project-scoped client credential designed to be handed out to clients, covering end-user actions such as reading, filing and voting. Never ship a key that can administer your project.
What you should still avoid is committing the key to your repository. The conventional Android answer is local.properties, which is git-ignored by default, read into a BuildConfig field at build time:
import java.util.Properties
val localProps = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { load(it) }
}
android {
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigField(
"String",
"FEEDBACKKIT_API_KEY",
"\"${localProps.getProperty("feedbackkit.apiKey") ?: ""}\""
)
}
}
The buildConfig flag matters: recent Android Gradle Plugin versions do not generate BuildConfig unless you ask. On CI, read the same value from an environment variable instead.
3. Render the list, the detail screen and the form
The smallest useful screen is a list, a button that opens the submit form, and a detail view:
@Composable
fun FeedbackScreen() {
val theme = if (isSystemInDarkTheme()) FeedbackKitTheme.Dark else FeedbackKitTheme.Light
FeedbackKitProvider(theme = theme) {
val navController = rememberNavController()
val listState = rememberFeedbackListState()
var selected by remember { mutableStateOf<Feedback?>(null) }
NavHost(navController = navController, startDestination = "list") {
composable("list") {
LaunchedEffect(Unit) { listState.refresh() }
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate("submit") }) {
Icon(Icons.Default.Add, contentDescription = "Submit feedback")
}
}
) { padding ->
FeedbackList(
modifier = Modifier.fillMaxSize().padding(padding),
state = listState,
onFeedbackClick = { feedback ->
selected = feedback
navController.navigate("detail")
}
)
}
}
composable("detail") {
selected?.let { feedback ->
FeedbackDetailView(
feedback = feedback,
onBack = { navController.popBackStack() }
)
}
}
composable("submit") {
SubmitFeedbackView(
onBack = { navController.popBackStack() },
onSubmitSuccess = { newFeedback ->
listState.addFeedback(newFeedback)
navController.popBackStack()
}
)
}
}
}
}
A few things are worth pointing out. rememberFeedbackListState issues its load when it is created, and exposes loading, refreshing and error state. FeedbackList has no pull gesture: call refresh() from a gesture you supply, and read isRefreshing for the indicator. The LaunchedEffect reloads the list each time the user comes back to it, which on 1.0.1 is what makes a vote cast on the detail screen show on the card. FeedbackDetailView takes a whole Feedback object, not an id. addFeedback puts a new submission at the top of the list without a reload. Feedback is not parcelable, so the example keeps the tapped request in state and reads it in the detail route, the same way the library's own example app does. Navigation-compose is your app's dependency, not the SDK's.
The form has a title, a category picker (Feature Request, Bug Report, Improvement or Other, starting at Feature Request), a description and an optional email field. Submit stays disabled until the title and description are filled in, and a failure shows its message above the button. The detail screen brings a top app bar titled "Feedback Details", with a back arrow when you pass onBack.
The list's loading, empty and error states are yours to replace, through loadingContent, emptyContent and errorContent:
FeedbackList(
state = listState,
onFeedbackClick = onOpen,
emptyContent = {
Text("No ideas yet. What should Trailmate do next?")
}
)
FeedbackList also has a simpler overload that manages its own state and takes filters directly, which is what you want if the board should show triaged items alone:
FeedbackList(
statusFilter = FeedbackStatus.APPROVED,
onFeedbackClick = { feedback -> /* open detail */ }
)
To filter a list you hold the state for, start it filtered and change the filter later. The filters run on the server, which returns the matching requests with the most-voted at the top:
val listState = rememberFeedbackListState(
initialStatusFilter = FeedbackStatus.APPROVED
)
// Later, from your own chips or menu:
listState.filterByCategory(FeedbackCategory.BUG_REPORT)
listState.clearFilters()
Theming
FeedbackKitTheme is a data class with Light and Dark presets. Start from one and change what you need with copy, so the board does not look bolted on:
val trailmateGreen = Color(0xFF2E7D32)
val base = if (isSystemInDarkTheme()) FeedbackKitTheme.Dark else FeedbackKitTheme.Light
val theme = base.copy(
primaryColor = trailmateGreen,
voteActiveColor = trailmateGreen,
statusColors = StatusColors(testflight = Color(0xFF00897B))
)
The provider does not follow the system's dark mode by itself, so choose the preset as above. Besides colours for text, cards, borders, errors and the vote button, the theme holds borderRadius, spacing and cardElevation. Status colours default to gray, blue, orange, cyan, green and red.
Your own screens
For a screen the composables do not cover, call the client directly. Here Trailmate's home screen shows its most-wanted Approved request:
@Composable
fun MostWanted() {
var top by remember { mutableStateOf<Feedback?>(null) }
LaunchedEffect(Unit) {
top = try {
FeedbackKit.shared.feedback
.list(ListFeedbackOptions(status = FeedbackStatus.APPROVED))
.maxByOrNull { it.voteCount }
} catch (e: FeedbackKitError) {
null
}
}
top?.let { feedback ->
ListItem(
headlineContent = { Text(feedback.title) },
trailingContent = { Text("${feedback.voteCount}") }
)
}
}
The client's other calls include feedback.get(id) and feedback.create(title, description, category). Every call is a suspend function that throws a FeedbackKitError: a repeat vote is a ConflictError, each case has a userMessage for the screen, and isRecoverable is true for network and server errors, the ones worth a retry.
4. Where the entry point belongs
- A settings row. Lowest friction to build, lowest traffic. Good when feedback is a secondary concern.
- A navigation destination, a tab or a drawer item. Right when the board is part of the product story and you want people browsing what others asked for.
- A modal bottom sheet, opened from wherever frustration happens. Highest intent, because the user writes while the problem is in front of them.
The sheet is worth a snippet, because SubmitFeedbackView takes a showAppBar flag that lets it sit inside a container that already has its own chrome:
var showSubmit by remember { mutableStateOf(false) }
if (showSubmit) {
ModalBottomSheet(onDismissRequest = { showSubmit = false }) {
SubmitFeedbackView(
showAppBar = false,
onSubmitSuccess = { showSubmit = false }
)
}
}
Depending on your Material 3 version, ModalBottomSheet may still require an @OptIn(ExperimentalMaterial3Api::class) annotation.
5. What happens on the server
A submitted item lands in a pipeline with six statuses: Pending, Approved, In Progress, TestFlight, Completed and Rejected. Android users see the TestFlight name too, so if your team uses that status for beta builds, a word in your app about where betas come from helps. Completed and Rejected close voting, and the SDK knows it: FeedbackStatus.canVote is false for those two, so the vote button renders disabled rather than firing a request the server would refuse. Here is how incoming requests look on the FeedbackKit side, with sample data from Trailmate, a fictional hiking app:
Where the requests land
Submitting also casts a vote for the person who submitted, so a brand new item shows one vote, not zero. Your team gets a push notification in the admin app for each new request and comment. On Pro and up, an item can be pushed into GitHub, Linear, ClickUp, Notion, Monday.com, Trello, Airtable, Asana or Basecamp, and new ones posted to Slack. Capabilities are not uniform: GitHub, for one, has no comment sync. Check the integrations page for the per-provider matrix before you build a workflow that assumes a capability exists.
Troubleshooting
| Symptom | Cause |
|---|---|
| Voting throws a validation error mentioning a required user id | No user id is set. 1.0.1 does not generate one, so set it in configure. |
| A vote rolls back, then appears after a reload | The 1.0.1 vote-reply defect above. The vote was saved. |
| The detail screen never shows comments | The 1.0.1 comment-reply defect above, not your setup. |
| Every request fails with an authentication error | A 401 maps to AuthenticationError. Usually the wrong key, or the right key pointed at the wrong environment. |
| Works against production, fails against a local server | Environment.LOCAL is plain HTTP on 10.0.2.2. Android blocks cleartext traffic by default, so permit that host in a network security config used by debug builds. |
| Release build fails to parse responses, debug build is fine | An R8 problem. The library ships consumer rules that keep its classes, so check nothing in your own configuration overrides them. |
| Submissions succeed but nobody else sees them | Either a status or category filter is applied, or the project is over the Free plan's cap. See below. |
That last one deserves the detail. On the Free plan a project shows 10 requests. Submissions past that are still accepted and still return successfully to your app, with no 402: the submitter still sees theirs, and everyone else's list leaves them out. If the counts stop adding up, check the plan on pricing before you go hunting through your Compose state. FeedbackList renders the same designed empty state whether the board is empty or a filter excluded everything, so rule out the filter.
Testing tips
Configure before the board appears. The composables read FeedbackKit.shared, which throws until configure has run. Configuring in Application.onCreate settles that, as long as the class is registered in the manifest.
A wrong key shows an error screen. When the server rejects the key, the list shows its error state with "Authentication failed. Please check your API key."
Keep test requests off the real board. Everything you file while testing lands in the project your key belongs to. Pro includes two projects, so you can give debug builds a key from a second one. Otherwise, delete test requests in the admin app afterwards.
Use previews with sample data. FeedbackCard, StatusBadge and CategoryBadge render from a Feedback value, which has a public constructor, so they work in a @Preview without a server. Do not tap the vote button in a preview: it calls FeedbackKit.shared.
Where to go next
The Kotlin SDK reference has the method surface and the badge composables, and the OpenAPI description covers the HTTP layer underneath. If this app is one of several, the docs index lists the other SDKs, and the Flutter guide covers a cross-platform app: the statuses and board are shared, so an Android user and an iOS user vote on the same row.
Every plan includes the Kotlin SDK. Start free, copy your project key, and the board can be in your next internal build.