Skip to main content
RapidDev - Software Development Agency
App Featuresanalytics-admin18 min read

How to Add a Help Desk to Your App (Copy-Paste Prompts Included)

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.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Intermediate

Category

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

Mobile

Everything it takes to ship a Help Desk — parts, prompts, and real costs.

TL;DR

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.

Layers:UIBackendService

Ticket submission form

UI

Flutter 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

Backend

The 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

UI

Flutter 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

UI

Flutter 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

Backend

A 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

Service

A 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):

schema.sql
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);
12
13create 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);
21
22create 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);
27
28-- Indexes
29create index support_tickets_user_updated_idx
30 on public.support_tickets (user_id, updated_at desc);
31create index ticket_messages_ticket_created_idx
32 on public.ticket_messages (ticket_id, created_at asc);
33
34-- RLS
35alter 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;
38
39create policy "Users can view own tickets"
40 on public.support_tickets for select
41 using (auth.uid() = user_id);
42
43create policy "Users can insert own tickets"
44 on public.support_tickets for insert
45 with check (auth.uid() = user_id);
46
47create policy "Users can update own tickets"
48 on public.support_tickets for update
49 using (auth.uid() = user_id);
50
51create policy "Users can view messages on own tickets"
52 on public.ticket_messages for select
53 using (
54 EXISTS (
55 SELECT 1 FROM public.support_tickets
56 WHERE id = ticket_id AND user_id = auth.uid()
57 )
58 OR sender_id = auth.uid()
59 );
60
61create policy "Users can insert own messages"
62 on public.ticket_messages for insert
63 with check (auth.uid() = sender_id AND sender_role = 'user');
64
65create policy "Users can manage own FCM token"
66 on public.user_fcm_tokens for all
67 using (auth.uid() = user_id)
68 with check (auth.uid() = user_id);
69
70-- Auto-update support_tickets.updated_at when a message is added
71create or replace function public.touch_ticket_on_message()
72returns trigger language plpgsql security definer as $$
73begin
74 update public.support_tickets
75 set updated_at = now()
76 where id = new.ticket_id;
77 return new;
78end;
79$$;
80
81create trigger ticket_messages_touch_ticket
82 after insert on public.ticket_messages
83 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.

Hand-built by developersFit for this feature:

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

  1. 1Design the ticket routing model: agent assignment table, queue logic (round-robin or skill-based), and SLA timer (pg_cron escalation job)
  2. 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
  3. 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
  4. 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.

ServiceWhat it doesFree tierPaid from
SupabaseTicket database, Realtime thread subscription, Storage for screenshot attachmentsFree: 500MB DB, 1GB Storage, 200 Realtime connectionsPro $25/mo (8GB DB, 100GB Storage, 500 connections)
Firebase Cloud MessagingPush notification to mobile user when an agent repliesFree up to 250K messages/monthFree (no paid tier for FCM)
ResendEmail confirmation on ticket creationFree: 3,000 emails/monthPro $20/mo for 50K emails (approx)
Slack Incoming WebhooksAlert the support team when a new ticket arrivesFree (no limit on Incoming Webhooks)Free
IntercomManaged alternative — replaces the custom build entirely for teams that prefer a SaaS support platformNo free tier for production useStarter 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

$0/mo

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

1

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

2

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

3

Add a NOT NULL CHECK (length(description) >= 20) constraint on support_tickets so the database rejects empty reports that the mobile form validator misses

4

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

5

Set support_tickets.updated_at via a Postgres trigger on ticket_messages INSERT rather than trusting the Flutter client to send the correct timestamp

6

Design the attachment path as user_id/ticket_id/filename so Storage RLS can verify ownership without an extra database lookup

7

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.

Get a fixed-price quote

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.

RapidDev

Need this feature production-ready?

RapidDev builds a help desk into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.