A zero-cost, GitHub-powered feedback system for iOS and macOS apps. Collect bug reports and feature requests from your users without any monthly subscription fees.
- Zero Cost - Uses GitHub Issues for storage and Cloudflare Workers free tier
- No Database Required - GitHub is your database
- Privacy Friendly - No user accounts needed, anonymous feedback
- Full Control - Your data stays in your GitHub repository
- Cross-Platform - Works on iOS 17+ and macOS 14+
| Feedback board | Duplicate detection | Success | Smart prompt |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Every screen is native SwiftUI. The composer derives the title from the first line, auto-detects bug vs. idea, and surfaces similar existing feedback while typing — so users vote instead of filing duplicates.
┌─────────────────┐ HTTPS ┌─────────────────────┐ GitHub API ┌─────────────────┐
│ iOS/macOS App │ ──────────────▶│ Cloudflare Worker │ ──────────────────▶│ GitHub Issues │
│ │◀────────────── │ (Edge Proxy) │◀────────────────── │ (Storage) │
└─────────────────┘ └─────────────────────┘ └─────────────────┘
- App sends feedback to your Cloudflare Worker
- Worker proxies requests to GitHub Issues API
- GitHub stores feedback as issues with labels
- Votes live in a bot-maintained ledger comment on each issue (anonymized device hashes) — GitHub is the source of truth, Cloudflare KV caches counts for speed
- One worker can serve your whole portfolio — map multiple apps to repos and route with an
appID
-
Create a Cloudflare account (free)
-
Install Wrangler CLI:
npm install -g wrangler wrangler login
-
Navigate to the worker directory:
cd Sources/Worker -
Create your config from the template and update it with your settings:
cp wrangler.toml.example wrangler.toml
name = "your-app-feedback" account_id = "your-cloudflare-account-id" [vars] REPO_OWNER = "your-github-username" REPO_NAME = "your-repo-name"
-
Set up GitHub credentials — two options:
GitHub App (recommended): never needs renewal, scoped to just your feedback repos. Create an App with Issues read/write permission, install it on your repo, then:
wrangler secret put GITHUB_APP_ID wrangler secret put GITHUB_APP_PRIVATE_KEY < app-pkcs8.pemSee Sources/Worker/SETUP.md for the full walkthrough (including the one-line key conversion).
Personal Access Token (simpler): create a classic token with
reposcope, then:wrangler secret put GITHUB_TOKEN # Paste your token when prompted -
Create the KV namespace used for caching, rate limiting, and vote tracking:
wrangler kv namespace create FEEDBACK_CACHE wrangler kv namespace create FEEDBACK_CACHE --preview
Copy the output IDs into your
wrangler.toml -
Deploy:
wrangler deploy
You'll get a URL like:
https://your-app-feedback.your-subdomain.workers.dev
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/rshankras/IndieFeedback", from: "1.2.0")
]Or in Xcode: File → Add Package Dependencies → Enter the repository URL
import IndieFeedback
struct ContentView: View {
var body: some View {
IndieFeedbackView(
workerURL: "https://your-app-feedback.workers.dev"
)
}
}struct MyView: View {
@State private var showFeedback = false
var body: some View {
Button("Send Feedback") {
showFeedback = true
}
.sheet(isPresented: $showFeedback) {
IndieFeedbackView(
workerURL: "https://your-app-feedback.workers.dev",
appName: "MyApp"
)
}
}
}Every entry point is one line, and they all accept the same optional appID:/apiSecret: parameters:
| Entry point | One-liner | Best for |
|---|---|---|
| Full feedback board | IndieFeedbackView(workerURL:) |
A sheet, tab, or navigation destination |
| Shake gesture / ⌘⇧F | .shakeToFeedback(workerURL:) |
Ambient, app-wide capture |
| Floating button | .feedbackButton(workerURL:) |
Betas & feedback-forward apps |
| Settings row | FeedbackSettingsRow(workerURL:) |
Where users look first |
| macOS Help menu | FeedbackCommands() |
Every Mac app (it's the convention) |
| Smart prompt | .feedbackPrompt(workerURL:trigger:) |
Proactively asking at the right moment |
| Programmatic | FeedbackCenter.present() + .feedbackSheet(workerURL:) |
Custom buttons, error screens, onboarding |
| Shipped toast | .feedbackShippedToast(workerURL:) |
Closing the loop when voted items ship |
Recommended recipes:
- Minimal (utility app):
FeedbackSettingsRow+.shakeToFeedback - Standard (most apps): those two +
.feedbackShippedToast+.feedbackPrompt(trigger: .afterEvents(...)) - Feedback-forward (beta, building in public): add
.feedbackButtonand surface the full board in your menu - macOS: always add
FeedbackCommands()— two lines, matches every Mac app users know
Don't stack every capture entry point at once — a Settings row, one ambient gesture, and a well-timed prompt covers discovery without feeling naggy.
Users often don't notice feedback options buried in settings. IndieFeedback provides optional components to make feedback more discoverable:
On iOS, users can shake their device to instantly open the feedback form. On macOS, the keyboard shortcut Cmd+Shift+F does the same.
ContentView()
.shakeToFeedback(workerURL: "https://your-worker.workers.dev")Tip: Mention this during onboarding - "Shake anytime to send feedback!"
A persistent button that floats over your content, always visible and accessible.
// As a view modifier (easiest)
TabView { ... }
.feedbackButton(
workerURL: "https://your-worker.workers.dev",
alignment: .bottomTrailing
)
// Or as a standalone view
ZStack(alignment: .bottomTrailing) {
MainContent()
FeedbackButton(
workerURL: "https://your-worker.workers.dev",
style: .labeled("Feedback"),
tint: .blue
)
.padding()
}Button styles:
.compact- Icon only.expanded- Expands on hover (macOS).labeled("Text")- Icon with custom text
Meet users where they already look. On the Mac, feedback belongs in the Help menu:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.feedbackSheet(workerURL: "https://your-worker.workers.dev")
}
.commands {
FeedbackCommands() // Help → Send Feedback…
}
}
}And in your Settings screen, drop in a ready-made row that opens the full feedback board:
Section("Support") {
FeedbackSettingsRow(workerURL: "https://your-worker.workers.dev")
}If you instrument feedback opens, the row takes an optional onPresent hook:
FeedbackSettingsRow(workerURL: "https://your-worker.workers.dev") {
Analytics.logEvent("feedback_opened", parameters: ["source": "settings"])
}You can also trigger the composer programmatically from anywhere with FeedbackCenter.present().
A non-intrusive prompt that appears after certain conditions are met.
ContentView()
.feedbackPrompt(
workerURL: "https://your-worker.workers.dev",
trigger: .afterSessions(5), // Show after 5 app opens
cooldown: .days(30) // Don't ask again for 30 days
)Trigger options:
.afterEvents("export-done", count: 3)- Recommended: right after a positive moment (record them withFeedbackEvents.record("export-done")) — asking after the user succeeded converts far better than asking at launch.afterSessions(5)- After N app sessions.afterDays(7)- After N days since install.afterSessionsAndDays(sessions: 3, days: 7)- Both conditions.immediately- For testing
The retention loop no hosted service can match: because votes are tracked per device, the app can detect when something this user voted for ships and celebrate it:
ContentView()
.feedbackShippedToast(workerURL: "https://your-worker.workers.dev")When feedback the user voted on moves to completed or released, they get a one-time "🎉 You asked, we built it!" toast with a button to see what's new. Checks run at most every 6 hours and never celebrate items that shipped before the user first saw them.
Customize the message:
.feedbackPrompt(
workerURL: "https://your-worker.workers.dev",
trigger: .afterSessions(5),
cooldown: .weeks(4),
title: "Enjoying MyApp?",
message: "We'd love to hear what you think!",
positiveButton: "Share Feedback",
negativeButton: "Not Now"
)- One-box composer: type a thought, the title and type (bug/idea) are derived automatically — no form schema
- Duplicate detection while typing: similar existing feedback appears with a "vote instead" action
- Vote on existing feedback, with a success screen that shows the created item (not a dead-end alert)
- View feedback status (Open, Planned, In Progress, Completed, Released)
- A "You asked, we built it" toast when something they voted for ships
- Add comments to feedback items
- Search and filter feedback
- All feedback appears as GitHub Issues
- Use GitHub labels for organization
- Manage status via GitHub labels
- Reply to users through GitHub comments
- View voting data in issue comments
IndieFeedback uses these labels to organize feedback:
| Label | Purpose |
|---|---|
user-feedback |
All feedback items |
bug |
Bug reports |
enhancement |
Feature requests |
status:planned |
Marked for implementation |
status:in-progress |
Currently being worked on |
status:completed |
Implementation complete |
status:released |
Available in a release |
Create these labels in your GitHub repository for proper categorization.
| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Health check |
/feedback/list |
GET | List all feedback |
/feedback/search |
GET | Search feedback (duplicate detection) |
/feedback/submit |
POST | Submit new feedback |
/feedback/:id |
GET | Get feedback details |
/feedback/:id/vote |
POST | Toggle vote |
/feedback/:id/comment |
POST | Add comment |
/roadmap |
GET | Get milestones |
/changelog |
GET | Get releases |
| IndieFeedback | Canny / Sleekplan | WishKit | Fider (self-hosted) | |
|---|---|---|---|---|
| Cost | $0 (free tiers) | Free tier, then $/mo | Free tier, then $/mo | ~$5+/mo VPS |
| Native SwiftUI UI | ✅ | Web embed | ✅ | Web |
| Where feedback lives | Your GitHub Issues | Their database | Their database | Your database |
| Infrastructure you run | One Cloudflare Worker | None | None | Server + DB |
| Reply to users from | GitHub (incl. mobile app) | Their dashboard | Their dashboard | Their web UI |
The trade-off is honest: setup takes ~30 minutes instead of five, and it's Apple-platforms-only. In exchange you own every byte, pay nothing, and triage feedback with the GitHub workflow you already use.
The repository includes example apps demonstrating all components — both consume the actual package via a local path dependency, so they always exercise the real API:
IOSExample/- iOS app with shake gesture, floating button, and smart promptMacOSExample/- macOS app with Cmd+Shift+F shortcut, expandable button, and smart prompt
Both examples show how to combine multiple components together. Point workerURL at your deployed worker and run them to see the features in action (opening the example projects requires Xcode 16+).
If you ship several apps, you don't need a worker deployment per app. Map app IDs to repos in wrangler.toml:
APPS = '{"myapp": {"owner": "you", "repo": "MyApp"}, "otherapp": {"owner": "you", "repo": "OtherApp"}}'Then pass the ID from each app:
IndieFeedbackView(
workerURL: "https://feedback.your-subdomain.workers.dev",
appID: "myapp"
)Adding a new app to your portfolio becomes a config line plus a redeploy. Requests without an appID fall back to the worker's REPO_OWNER/REPO_NAME, so existing single-app setups keep working unchanged.
IndieFeedbackView(
workerURL: String, // Required: Your Cloudflare Worker URL
appName: String?, // Optional: App name for Keychain storage
appID: String?, // Optional: app ID for multi-tenant workers (?app=)
apiSecret: String? // Optional: shared secret (X-API-Secret header)
)- GitHub: Free (public or private repos)
- Cloudflare Workers: Free tier includes 100,000 requests/day
- Cloudflare KV: Free tier includes 100,000 reads/day, 1,000 writes/day
For most indie apps, you'll never exceed the free tiers.
- iOS 17.0+ / macOS 14.0+
- Swift 5.9+
- Xcode 15.0+
- No user accounts required
- Device ID stored locally in Keychain (for vote tracking only)
- Votes are recorded in GitHub as SHA-256 hashes of the device ID — nothing identifying appears in your repo
- No personal data collected
- All data stored in your own GitHub repository
- PAT mode: verify your GitHub token has
reposcope and is set (wrangler secret list) - GitHub App mode: verify the App is installed on the target repo, and that the private key was converted to PKCS#8 (see
Sources/Worker/SETUP.md) - If the response says
Unauthorizedwith no GitHub error, your worker hasAPI_SECRETset — pass the same value asapiSecretin the Swift client
- Verify KV namespaces are created and bound in
wrangler.toml - Check KV namespace IDs match
- Confirm the worker URL is correct (no trailing slash)
- Check GitHub repo has the required labels created
- Verify network connectivity
MIT License - See LICENSE file for details.
Created by Ravi Shankar




