One feedback inbox for seven iOS apps: a Supabase edge function
A shared submit-feedback endpoint for seven indie apps: validation, rate limits without storing IPs, private screenshots, Telegram alerts, RLS with no policies.

On this page
Until this week, the only way to tell me something was wrong in one of my apps was to find the support email on the App Store page and write a message. Almost nobody does that. The people who hit a bug in Lockboxy or Baton just leave, and the ones with a good idea keep it to themselves.
So I built a proper "Feedback & bug report" screen. Seven apps (Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton and Stampzy) means seven forms, but I wanted one backend, one table and one place to read everything. This post is about that shared piece: a single Supabase edge function called submit-feedback, the table behind it, and the form that calls it. It also covers the privacy label, which for apps that promise "no analytics" matters as much as the code.
One public endpoint, no accounts
None of my apps has a login. Lockboxy is an offline vault, Baton is a baby log that never leaves the phone, and so on. So the endpoint has to accept a request from an app that has no Supabase session, which in Supabase terms means verify_jwt = false for this one function:
# supabase/config.toml
[functions.submit-feedback]
verify_jwt = falseIt lives in the Supabase project that already hosts Linkeeper's backend, because that project existed and the free plan has room. The app sends one JSON body:
{
"app": "baton",
"kind": "bug",
"message": "The night timer kept running after I stopped it",
"email": "optional",
"appVersion": "1.1.1",
"osVersion": "26.0",
"deviceModel": "iPhone",
"locale": "en",
"installId": "a random UUID made on first launch",
"screenshotBase64": "optional",
"screenshotMime": "image/jpeg"
}The answer is just as small: 200 {"ok":true,"id":"…"}, or a status with a machine-readable code such as 400 invalid_email, 413 screenshot_too_large or 429 rate_limited_install. A 500 says internal_error and nothing more. The details go to the function log, never into the response.
Validate everything before writing anything
A public endpoint gets garbage eventually, so the order of operations matters most. The function is split in two: lib.ts holds pure helpers with no I/O (validation, base64 sizing, the Telegram formatter), covered by deno test, and index.ts does the I/O. The request goes through these steps, and nothing touches the database until the cheap checks pass.

- Size. The body must be at most 7 MB, checked against
content-lengthfirst and then against the bytes actually read. - Shape.
appmust be one of the seven,kindone ofbug,idea,other. The message must be 3 to 5,000 characters after trimming. Optional strings have caps (32 for the version, 64 for the device model, 35 for the locale), and blank ones becomenull. The install ID must match[A-Za-z0-9-]{8,64}. - Screenshot. If there is one, the decoded size must be at most 5 MB, and the file signature has to match the declared type:
FF D8 FFfor JPEG, the eight-byte PNG header,ftypat offset 4 for HEIC. That's a cheap way to stop someone storing arbitrary files in my bucket. - Rate limits, covered below.
- Insert the row, then upload the screenshot, then notify.
One small detail paid for itself in tests: the message length is counted in Unicode code points, not UTF-16 units, so it matches Postgres's char_length() and the table's own check constraint. A 5,000-character message full of emoji passes both, or fails both.
/** Length in Unicode code points — matches Postgres char_length(). */
export function codePointLength(s: string): number {
return Array.from(s).length;
}Rate limits without keeping IP addresses
There are two limits, and they live in different places for a reason.
5 per install per hour. Each app generates a random UUID on first launch and stores it locally. It isn't tied to an account or the advertising ID. The function counts the rows in app_feedback with that install_id from the last hour, using a partial index on (install_id, created_at desc) where install_id is not null. No extra table needed.
20 per IP per hour. This one catches someone who strips the install ID. I didn't want IP addresses in my database, so the function stores a hash instead:
const ip = clientIp(req.headers); // first hop of X-Forwarded-For
const ipKey = ip ? await sha256Hex(`submit-feedback:${ip}`) : null;Those hashes go into a tiny ledger table, app_feedback_rate (key, created_at). Every request deletes rows older than an hour before counting, so the table never holds more than one hour of hashes.
Why a table and not a Map in memory? Edge function isolates are short-lived and not shared between requests in any way you can rely on. An in-memory counter would reset constantly and limit almost nothing.
Requests rejected with 400, 413 or 429 are not counted. A user fixing a typo in their email shouldn't burn their quota.
Screenshots stay private
Screenshots go into a Storage bucket called feedback-screenshots that is private, capped at 5 MB per file and limited to JPEG, PNG and HEIC at the bucket level too. The path is <app>/<feedback id>.<ext>. Nothing ever creates a public URL. When I want to look, the admin console signs a URL that expires after an hour.
The upload happens after the insert, and a failed upload doesn't fail the request. The feedback is already stored, which is the part that matters, so the function logs the error and leaves screenshot_path empty.
A Telegram ping, escaped
When the bot secrets are set, every submission pings me on Telegram:
🐞 Baton · Bug
The night timer kept running after I stopped it
📱 1.1.1 · iOS 26.0 · iPhone · en
✉️ none
🆔 3f2a9c1eThe message uses Telegram's HTML parse mode, so every user-supplied value goes through an escaper. Otherwise a message containing <b> or a stray & would either break the formatting or make Telegram reject the whole call:
export function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}The text is cut at 1,500 characters, by code point again, so an emoji never gets split in half. A screenshot follows as a photo, sent from the bytes the function already has, so no URL is created for Telegram either. HEIC goes as a document, because sendPhoto doesn't accept it.
Three rules keep this side harmless. A Telegram failure never fails the request. Missing secrets skip the notification without an error, and the function reads them on every request, so setting them needs no redeploy. And the error handler never logs the fetch URL, because the bot token is part of it.
RLS on, no policies
Both tables have row level security enabled and, in the first migration, no policies at all:
alter table public.app_feedback enable row level security;
alter table public.app_feedback_rate enable row level security;
-- no create policy: anon and authenticated can neither read nor writeThat's deliberate. The function writes with the service role, which bypasses RLS. Every other client, including anyone who pulls the public anon key out of an app binary, can't read feedback, insert rows that skip validation, or see the rate ledger. The only way in is through the function.

Reading the inbox came later, through a separate migration that grants a narrow set of rights to one admin. That's the next post: an admin console that costs nothing to host.
The form in the app
Each app got its own screen, built the same way. Baton's is a good example. It opens from Settings › About, from the More tab, and from a baton://feedback?kind=bug deep link, so a What's New entry can jump straight to it.
- Bug, Idea or Other, with a different placeholder for each ("What happened, and what did you expect?" for a bug).
- Email (optional), with the hint "Only if you want a reply."
- Screenshot (optional) in the apps that already had an image picker. Baton re-encodes the picked image to JPEG (at most 2,048 px, quality 0.8, EXIF and GPS dropped) and caps it at 1.5 MB, far below the server's 5 MB. Linkeeper and Talkzy have no picker, so their forms skip screenshots instead of adding a permission just for this.
- "What gets sent", a disclosure that lists every field: the message and kind, email and screenshot only if added, the app version, iOS version, device type, language, and a random install ID "not linked to you or your household". Baton's form also says not to include the baby's name or health details.
The rule I wrote into the code is that this list must match the function that builds the request body, buildFeedbackBody. If one changes, the other must too, and the copy file says so in a comment.
The network call never throws. Every result becomes an outcome the screen knows how to show:
export type FeedbackOutcome =
| { status: 'sent'; id: string }
| { status: 'invalid'; field: string | null }
| { status: 'tooLarge' }
| { status: 'rateLimited' }
/** Offline, timed out, or a 5xx / non-JSON answer. */
| { status: 'unavailable' };unavailable covers more than you'd think. My smoke test of an 8 MB body didn't get the function's 413 back. Supabase's gateway cut it off first and answered 503. So the form treats any 5xx or non-JSON answer as "try again later", and offers Send by email instead: a mailto: link with the same text and metadata. The install ID stays out, since Mail already shows who wrote. An image can't ride in a mailto: link, so the user attaches one in Mail if they want.
The privacy label is part of the feature
For apps whose listing says "Data Not Collected" or close to it, a feedback form changes the App Privacy answers. I wrote the change down per app before shipping:
- User Content → Other User Content, plus Photos or Videos where screenshots can be attached. Purpose: Customer Support.
- Contact Info → Email Address, because the user may type one. Purposes: App Functionality, Customer Support.
- For all of them: not linked to the user's identity and not used for tracking.
The label has to change in App Store Connect together with the first build that contains the form, not after. Lockboxy 1.2.13 went to TestFlight with it today. The private vault App Review post has more on how reviewers read privacy claims.
Checklist
- One endpoint for every app, with the app name as a validated enum, not one backend per app.
verify_jwt = falseonly on the public function, and RLS with no policies on its tables.- Validate size, shape and file signature before the first write.
- Count lengths in code points so the function and Postgres agree.
- Rate-limit per install ID from the main table and per IP with a hashed, self-purging ledger. Don't use in-memory counters in edge functions.
- Private bucket, signed URLs only, and a failed upload never loses the message.
- Escape every user string in Telegram HTML, never log a URL that contains a token, and never let a notification fail the request.
- In the app: optional email and screenshot, a "What gets sent" list that matches the request body, outcomes instead of exceptions, and a
mailto:fallback. - Update the App Privacy label together with the build that ships the form.
How the seven apps share the rest of their stack is in Shipping seven iOS apps solo.
Related posts


