Feature spec
IntermediateCategory
analytics-admin
Build with AI
4–8 hours with FlutterFlow
Custom build
1–2 weeks custom dev
Running cost
$0/mo up to 100 users · $25–45/mo at 10K users
Works on
Everything it takes to ship a Help Desk — parts, prompts, and real costs.
An in-app help desk needs a ticket submission form, a per-ticket conversation thread with Supabase Realtime, push notifications when an agent replies (Firebase Cloud Messaging), and a screenshot attachment pipeline via Supabase Storage. With FlutterFlow you can build the user-facing side in 4–7 hours. Running cost is $0/month for up to 100 users — Supabase free tier, FCM free, and Resend's 3,000-email/month free tier cover it.
What an In-App Help Desk Actually Is
An in-app help desk lets users submit support tickets, track their status, and exchange messages with a support agent — all without leaving the mobile app or switching to email. Each ticket is a persistent thread: the user writes the first message (with an optional screenshot), the agent replies from a web panel or separate admin screen, and both sides see the conversation in real time. The three hard parts are screenshot attachment (image_picker → Supabase Storage), agent-reply push notifications (Supabase trigger → Edge Function → FCM), and RLS that guarantees users can only ever read their own tickets. Everything else — the form, the list, the chat-style thread — is straightforward Flutter UI.
What users consider table stakes in 2026
- Ticket submission form with subject, description field, and an optional screenshot attachment button
- Ticket list showing status badges (open, in progress, resolved) sorted by most recently updated
- Per-ticket conversation thread with user messages right-aligned and agent messages left-aligned, timestamped
- Push notification delivered to the user's device the moment an agent posts a reply
- Email confirmation sent to the user's address as soon as the ticket is created
- Optional FAQ or knowledge base search surfaced before the submission form to deflect common questions
Anatomy of the Feature
Six components working together. FlutterFlow handles the visual ones well; the attachment upload and push notification pipeline need custom action blocks.
Ticket submission form
UIFlutter Form with TextFormField widgets for subject and description (minimum 20 characters validated), an ImagePicker button using the image_picker package, and a Submit button that disables after the first tap to prevent duplicate submissions.
Note: Validates both fields before allowing submission. The ImagePicker must request NSPhotoLibraryUsageDescription on iOS or the app will crash on the first attachment attempt.
Supabase Storage attachment upload
BackendThe image_picker result is uploaded to a Supabase Storage bucket named 'support-attachments' in the path user_id/filename. Returns a signed URL that is stored in the support_tickets row. The bucket policy restricts writes to the authenticated user's own folder.
Note: The bucket RLS policy is the single most common build failure here. It must explicitly check that auth.uid()::text matches the first segment of the storage path.
Ticket list screen
UIFlutter ListView of support_tickets filtered by the current user's id, sorted by updated_at DESC. Each row shows subject, a Chip widget for status (orange = open, blue = in_progress, green = resolved), and the timestamp of the last message.
Note: Subscribe to a Supabase Realtime stream on support_tickets filtered by user_id to update status badges without requiring pull-to-refresh.
Conversation thread
UIFlutter ListView.builder of ticket_messages sorted by created_at ASC. User messages are right-aligned with a blue bubble, agent messages left-aligned with a grey bubble — mirrors iMessage layout using Flutter Align and Container widgets. Each message shows sender name and a relative timestamp.
Note: New messages from the agent must appear instantly via the Supabase Realtime subscription, not polling.
Supabase Realtime thread subscription
BackendA Supabase Realtime channel per ticket: supabase.channel('ticket-[ticketId]').on('postgres_changes', event INSERT on ticket_messages, filter ticket_id=eq.[ticketId], callback) rebuilds the message list when an agent posts. No polling needed.
Note: The filter parameter is critical. Without it, all message inserts across all tickets broadcast to every open thread screen.
Admin notification pipeline
ServiceA Supabase Edge Function triggered on ticket INSERT sends a Slack Incoming Webhook alert to the support team and an email confirmation to the user via Resend (free tier: 3,000 emails/month). Agent replies trigger a Postgres trigger that calls the Edge Function via pg_net, which sends an FCM push notification to the mobile user's device using their stored FCM token.
Note: FCM token must be stored in a user_fcm_tokens table when the app starts. The Postgres trigger on ticket_messages fires AFTER INSERT WHEN sender_role = 'agent'.
The data model
Two main tables plus a token store. Run this in the Supabase SQL Editor (Dashboard → SQL Editor → New query):
1create table public.support_tickets (2 id uuid primary key default gen_random_uuid(),3 user_id uuid references auth.users(id) on delete cascade not null,4 subject text not null,5 description text not null,6 status text not null default 'open'7 check (status in ('open', 'in_progress', 'resolved', 'closed')),8 attachment_url text,9 created_at timestamptz not null default now(),10 updated_at timestamptz not null default now()11);1213create table public.ticket_messages (14 id uuid primary key default gen_random_uuid(),15 ticket_id uuid references public.support_tickets(id) on delete cascade not null,16 sender_id uuid references auth.users(id) on delete cascade not null,17 sender_role text not null check (sender_role in ('user', 'agent')),18 body text not null,19 created_at timestamptz not null default now()20);2122create table public.user_fcm_tokens (23 user_id uuid references auth.users(id) on delete cascade primary key,24 token text not null,25 updated_at timestamptz not null default now()26);2728-- Indexes29create index support_tickets_user_updated_idx30 on public.support_tickets (user_id, updated_at desc);31create index ticket_messages_ticket_created_idx32 on public.ticket_messages (ticket_id, created_at asc);3334-- RLS35alter table public.support_tickets enable row level security;36alter table public.ticket_messages enable row level security;37alter table public.user_fcm_tokens enable row level security;3839create policy "Users can view own tickets"40 on public.support_tickets for select41 using (auth.uid() = user_id);4243create policy "Users can insert own tickets"44 on public.support_tickets for insert45 with check (auth.uid() = user_id);4647create policy "Users can update own tickets"48 on public.support_tickets for update49 using (auth.uid() = user_id);5051create policy "Users can view messages on own tickets"52 on public.ticket_messages for select53 using (54 EXISTS (55 SELECT 1 FROM public.support_tickets56 WHERE id = ticket_id AND user_id = auth.uid()57 )58 OR sender_id = auth.uid()59 );6061create policy "Users can insert own messages"62 on public.ticket_messages for insert63 with check (auth.uid() = sender_id AND sender_role = 'user');6465create policy "Users can manage own FCM token"66 on public.user_fcm_tokens for all67 using (auth.uid() = user_id)68 with check (auth.uid() = user_id);6970-- Auto-update support_tickets.updated_at when a message is added71create or replace function public.touch_ticket_on_message()72returns trigger language plpgsql security definer as $$73begin74 update public.support_tickets75 set updated_at = now()76 where id = new.ticket_id;77 return new;78end;79$$;8081create trigger ticket_messages_touch_ticket82 after insert on public.ticket_messages83 for each row execute procedure public.touch_ticket_on_message();Heads up: Agent writes (admin panel inserting messages with sender_role = 'agent') must use the Supabase service_role key in the Edge Function — the RLS policy restricts direct agent inserts from the client.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Custom development is the right path when you have more than two concurrent support agents, need SLA timers, ticket routing, or want to integrate with Intercom or Zendesk instead of building your own.
Step by step
- 1Design the ticket routing model: agent assignment table, queue logic (round-robin or skill-based), and SLA timer (pg_cron escalation job)
- 2Build the agent dashboard as a Next.js web app with TanStack Table for ticket queue, real-time Supabase subscription for new tickets, and a rich text reply editor with canned response library
- 3Integrate Intercom JavaScript SDK or Zendesk Widget as alternatives to the custom thread UI — both provide chat interfaces that replace the in-app thread while keeping your ticket data in Supabase
- 4Add multilingual support: store a preferred_language field on the user, auto-translate incoming messages with DeepL API before agents see them
Where this path bites
- Intercom Starter costs approximately $39/seat/month — for 3 agents that is $117/month versus ~$25/month for a self-built Supabase solution
- Building SLA timers, routing logic, and a canned response library from scratch adds 1–2 weeks to the timeline even for experienced developers
Third-party services you'll need
The base help desk runs entirely on free tiers. Costs appear only if email volume grows or you adopt a managed support platform.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Ticket database, Realtime thread subscription, Storage for screenshot attachments | Free: 500MB DB, 1GB Storage, 200 Realtime connections | Pro $25/mo (8GB DB, 100GB Storage, 500 connections) |
| Firebase Cloud Messaging | Push notification to mobile user when an agent replies | Free up to 250K messages/month | Free (no paid tier for FCM) |
| Resend | Email confirmation on ticket creation | Free: 3,000 emails/month | Pro $20/mo for 50K emails (approx) |
| Slack Incoming Webhooks | Alert the support team when a new ticket arrives | Free (no limit on Incoming Webhooks) | Free |
| Intercom | Managed alternative — replaces the custom build entirely for teams that prefer a SaaS support platform | No free tier for production use | Starter approx $39/seat/mo |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier handles tickets, messages, and Storage attachments. FCM free. Resend free (3K emails/mo easily covers 100 users). Slack webhooks free.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Screenshot attachment silently returns 403
Symptom: The AI creates the 'support-attachments' Supabase Storage bucket but generates no RLS policy for it, or generates one that only checks bucket_id without verifying the folder path. Authenticated uploads return 403 with no error message surfaced in the Flutter UI.
Fix: In Supabase Dashboard → Storage → Policies, add an INSERT policy with the condition: bucket_id = 'support-attachments' AND auth.uid()::text = (storage.foldername(name))[1]. This ensures each user can only write to their own subfolder. Test by uploading a file from a signed-in user before wiring the Flutter form.
Agent replies never trigger push notifications
Symptom: The Edge Function that sends FCM push is described in the prompt and even generated by the AI, but there is no Postgres trigger that actually invokes it when an agent posts a message. The ticket_messages table gets the row; the user's phone gets nothing.
Fix: Create a Postgres trigger on ticket_messages: AFTER INSERT WHEN (new.sender_role = 'agent'), call the Edge Function via pg_net.http_post() passing the ticket owner's FCM token retrieved from user_fcm_tokens. Verify the trigger exists in Supabase Dashboard → Database → Triggers before testing end-to-end.
All ticket thread messages appear in every open thread
Symptom: The Supabase Realtime subscription on ticket_messages is set up without a filter. Every message insert — across every ticket — broadcasts to every open thread screen. Users see other people's messages if they have two threads open simultaneously.
Fix: Add the filter parameter to the channel subscription: supabase.channel('thread-[ticketId]').on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'ticket_messages', filter: 'ticket_id=eq.[ticketId]' }, callback). The ticketId must be the specific UUID of the ticket currently being viewed.
Ticket status badge does not update after agent changes status
Symptom: The ticket list screen performs a one-time fetch on page load. When an agent changes a ticket from 'open' to 'in_progress', the user still sees the old orange badge until they leave and re-enter the screen.
Fix: Add a Realtime subscription on support_tickets filtered by user_id = auth.uid(). When a status UPDATE event fires, rebuild the ticket list. In FlutterFlow, use a Realtime Stream backend query on the ticket list page rather than a one-time fetch query.
iOS build crashes on first attachment attempt
Symptom: Flutter's image_picker package requires NSPhotoLibraryUsageDescription and NSCameraUsageDescription keys in Info.plist. If these are absent, iOS terminates the app immediately when the user taps the attachment button. FlutterFlow does not add these by default.
Fix: In FlutterFlow, go to Settings → App Details → iOS Permissions and add both keys with descriptive strings: 'Used to attach screenshots to your support ticket.' For custom Flutter builds, add the keys directly to ios/Runner/Info.plist in Xcode. Vague strings like 'Camera access' may cause App Store review rejection.
Best practices
Store FCM tokens in a user_fcm_tokens table and refresh them on every app start — tokens expire and rotating them prevents missed push notifications
Filter the Realtime ticket_messages subscription by ticket_id on every thread screen — missing this filter broadcasts every message in your system to every open screen
Add a NOT NULL CHECK (length(description) >= 20) constraint on support_tickets so the database rejects empty reports that the mobile form validator misses
Use service_role key only inside Supabase Edge Functions — never ship it in the Flutter app bundle; agent actions that bypass RLS belong in the Edge Function layer
Set support_tickets.updated_at via a Postgres trigger on ticket_messages INSERT rather than trusting the Flutter client to send the correct timestamp
Design the attachment path as user_id/ticket_id/filename so Storage RLS can verify ownership without an extra database lookup
Send the email confirmation via Resend inside the same Edge Function that notifies Slack — one trigger, two notifications, and the user has a paper trail immediately
When You Need Custom Development
FlutterFlow covers a single-agent help desk well. The following scenarios push past its limits:
- More than two concurrent support agents need ticket assignment, queue management, and a dashboard showing who is handling what — FlutterFlow cannot generate this routing logic
- SLA timers must escalate tickets automatically (e.g. no response in 4 hours → manager alert) using a pg_cron job with conditional logic the AI build paths do not scaffold
- Integration with an existing CRM (Salesforce, HubSpot) is required to link tickets to customer records, deal history, and customer health scores
- Multilingual support needs auto-translation of user messages before agents see them, and translated agent replies delivered back in the user's language
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
Can I add a FAQ search before the ticket form to reduce ticket volume?
Yes, and it is worth doing. Add a search field above the 'Submit a ticket' button that queries a knowledge_base table in Supabase with a full-text search (Postgres tsvector). Show the top 3 matching articles in a list. Only if the user taps 'None of these helped' does the ticket form appear. Teams that add this step typically see 20–40% fewer tickets submitted.
How do I let users attach screenshots to their tickets?
Use Flutter's image_picker package. On form submit, upload the picked image to a Supabase Storage bucket called 'support-attachments' in a path matching user_id/filename. Store the returned signed URL in the support_tickets row. The bucket RLS policy must restrict writes to auth.uid()::text matching the first path segment, otherwise any authenticated user can overwrite any file.
Can I set up automatic email confirmation when a ticket is created?
Yes. Create a Supabase Edge Function triggered by a Postgres AFTER INSERT trigger on support_tickets. Inside the Edge Function, call the Resend API (free up to 3,000 emails/month) with the ticket subject and a confirmation message. The email goes to the address from auth.users. This takes about 30 minutes to set up and gives users immediate confirmation that their report was received.
How do I make sure only the ticket owner can see their support thread?
Add RLS policies on both tables. On support_tickets: SELECT using auth.uid() = user_id. On ticket_messages: SELECT using EXISTS (SELECT 1 FROM support_tickets WHERE id = ticket_id AND user_id = auth.uid()). This way even if someone guesses a ticket UUID, the database returns no rows. Test by querying from a different user's session in the Supabase SQL Editor.
Can I assign tickets to specific support agents?
Add an assigned_agent_id column (uuid FK to auth.users) to support_tickets. Build an agent admin panel (Lovable or a separate Next.js page) that shows unassigned tickets and allows an agent to claim or assign them. Add a check_constraint that only users with role = 'agent' in user_roles can be assigned. This is the first feature that makes custom development worth considering over FlutterFlow.
How do I add ticket priority levels?
Add a priority column with CHECK (priority IN ('low', 'medium', 'urgent')) to support_tickets. Let users select priority on the submission form, or derive it automatically: if the user's account age is less than 7 days, default to 'medium'; if they describe a billing issue (keyword match), set 'urgent'. Reflect priority in the ticket list with a color-coded indicator alongside the status badge.
Can the help desk integrate with Intercom or Zendesk instead of being built custom?
Yes, both offer mobile SDKs. Intercom's Flutter package (intercom_flutter) replaces the entire ticket UI with their managed chat widget. Zendesk provides zendesk_messaging for Flutter. The trade-off: you get a mature, feature-rich support platform immediately (typing indicators, bots, CSAT ratings) at approximately $39/seat/month for Intercom Starter, versus a self-built solution at ~$25/month total for Supabase Pro.
How do I track average response time across all tickets?
Add a Supabase view: SELECT AVG(EXTRACT(EPOCH FROM (first_reply_at - created_at)) / 3600.0) as avg_hours_to_first_reply FROM (SELECT t.created_at, MIN(m.created_at) as first_reply_at FROM support_tickets t LEFT JOIN ticket_messages m ON m.ticket_id = t.id AND m.sender_role = 'agent' GROUP BY t.id) sub. Display this in your admin panel. For per-agent stats, filter by assigned_agent_id once that column exists.
Need this feature production-ready?
RapidDev builds a help desk into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.