Build a help desk and support ticket dashboard with Gravity Forms
A complete pattern for handling support tickets on a WordPress site. Customer-facing submission form, agent triage dashboard, internal-vs-public conversation thread, SLA timer, customer self-service view, all from one Gravity Form.
Help-desk SaaS starts at $19 per agent per month and ramps fast. For a site that already has WordPress and Gravity Forms installed, you’re paying twice, once for the product, once for the help-desk seat that re-asks every customer who they are.
This guide ships the pattern most small teams actually need: customer-facing ticket submission, agent triage workspace, internal vs public reply threading, SLA breach alerting, and a customer-self-service view of their own tickets. One form, three pages, about an hour.
What you’ll build#
Three URLs:
/support, customer-facing ticket submission + their own ticket list/staff/queue, agent triage workspace (the inbox)/staff/queue/{id}, single-ticket view with conversation thread
Two Gravity Forms:
- Tickets form, one entry per ticket, with status / priority / assigned-agent / SLA fields
- Replies form, one entry per reply, linked to a ticket id, marked public or internal
Step 1: shape the tickets form#
Customer-facing fields:
subject, single-line textbody, paragraph, rich-text oncategory, dropdown (Billing, Technical, Account, Feature request, Other)customer_email, email (auto-populated for logged-in users)
Hidden admin-side fields:
status, Hidden, defaultopen. Values:open,pending_customer,pending_agent,resolved,closedpriority, Hidden, defaultnormal. Values:low,normal,high,urgentassigned_agent, Hidden, default empty. Stores a WP username.sla_due_at, Date/time, default empty. Computed by a hook on submission.customer_id, Hidden, default empty. WP user id (if customer is logged in).internal_notes, Paragraph, default empty. Agent-only commentary.
Step 2: shape the replies form#
A separate form so each reply is its own queryable entry:
ticket_id, Hidden, populated by JavaScript on the conversation pagebody, Paragraphis_internal, Checkbox (default off, public reply visible to customer)from_agent, Hidden, populated with the logged-in agent’s username
Step 3: set the SLA timer on submission#
A small hook in your child theme’s functions.php computes sla_due_at based on priority:
add_action('gform_after_submission_tickets', function ($entry, $form) {
$hours = match (rgar($entry, 'priority')) {
'urgent' => 4,
'high' => 24,
'normal' => 48,
'low' => 72,
default => 48,
};
$due = wp_date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
GFAPI::update_entry_field($entry['id'], 'sla_due_at', $due);
}, 10, 2);
If you don’t have an SLA, omit this, the table works fine without sla_due_at. If you do, the next pieces use it.
Step 4: build the customer-facing page (/support)#
Two stacked components: the submission form, and a per-customer table of their own tickets.
## Submit a new ticket
[gravityform id="tickets" title="false" description="false" ajax="true"]
## Your existing tickets
[gravity_table id="tickets"
columns="subject,category,status,priority,created"
filter_user_owns="customer_id"
allowed_roles="subscriber,customer,administrator"
allow_edit=""
filters="status"
per_page="10"
sort="created:desc"]
Three knobs doing the work:
filter_user_owns="customer_id", table auto-narrows to the logged-in customer’s ticketsallow_edit="", customer view is read-only; they submit edits as new replies, not by editing the ticket directlyallowed_roles="subscriber,customer,administrator", anyone logged in sees their own tickets; admins see all (with the per-user filter they see all because the column-match passes for admin viewing as a separate user via?gt_filter_customer_id=42URL params, otherwiseadministratoroverrides the per-user gate).
Step 5: build the agent triage workspace (/staff/queue)#
One shortcode, configured to give agents the controls they need without exposing them to customers:
[gravity_table id="tickets"
columns="created,subject,customer_email,category,status,priority,assigned_agent,sla_due_at"
allowed_roles="support-agent,support-lead,administrator"
allow_edit="status,priority,assigned_agent,internal_notes"
edit_permissions="assigned_agent:support-lead"
filters="status,priority,assigned_agent,category"
bulk="assign,close,export"
bulk_permissions="export:edit_others_posts"
audit_log="true"
auto_refresh="true"
refresh_interval="30"
sort="sla_due_at:asc,created:desc"
per_page="50"]
What this does:
- Sorts by
sla_due_at:asc, closest-to-breach floats to the top, ties broken by oldest-first assigned_agentonly editable bysupport-lead, agents work their assigned tickets; only leads reassignauto_refreshevery 30s, a new ticket appears in the queue without manual refresh- Bulk
assign/close/export, registerassignandclosevia thegt_register_bulk_actionsfilter (pattern matches the moderation-queue and CRM guides)
Saved view URLs#
A small navigation strip above the shortcode helps agents jump between common slices:
<nav aria-label="Queue views">
<a href="/staff/queue?gt_filter_assigned_agent=current_user>_filter_status=open,pending_customer">My open</a>
<a href="/staff/queue?gt_filter_status=open>_filter_assigned_agent=__unassigned__">Unassigned</a>
<a href="/staff/queue?gt_filter_priority=urgent">Urgent</a>
<a href="/staff/queue?gt_filter_status=pending_agent">Awaiting agent reply</a>
<a href="/staff/queue?gt_filter_status=pending_customer">Awaiting customer</a>
</nav>
Step 6: SLA-breach alerting#
Two complementary pieces. First, a visual cue in the table, render sla_due_at cells that are overdue with a red badge. Use the gt_render_cell filter:
add_filter('gt_render_cell', function ($html, $value, $field, $entry) {
if ($field['key'] !== 'sla_due_at' || !$value) return $html;
$due = strtotime($value);
$now = current_time('timestamp');
if ($due < $now && !in_array(rgar($entry, 'status'), ['resolved', 'closed'])) {
$html = '<span class="sla-breach" title="Overdue">⚠ ' . esc_html($value) . '</span>';
} elseif ($due - $now < 3600) {
$html = '<span class="sla-soon">' . esc_html($value) . '</span>';
}
return $html;
}, 10, 4);
Second, an hourly cron that pings on Slack/email when something breaches:
add_action('init', function () {
if (!wp_next_scheduled('gt_sla_breach_check')) {
wp_schedule_event(time(), 'hourly', 'gt_sla_breach_check');
}
});
add_action('gt_sla_breach_check', function () {
$now = wp_date('Y-m-d H:i:s');
$breaches = GFAPI::get_entries('tickets', [
'field_filters' => [
['key' => 'status', 'value' => ['open','pending_agent'], 'operator' => 'in'],
['key' => 'sla_due_at', 'value' => $now, 'operator' => '<'],
],
]);
if (empty($breaches)) return;
$count = count($breaches);
wp_remote_post(SLACK_WEBHOOK_URL, [
'body' => json_encode([
'text' => "🚨 {$count} tickets are past SLA. /staff/queue?gt_filter_status=open>_sort=sla_due_at:asc",
]),
]);
});
Hourly was deliberate, alerting more often turns the channel into noise; alerting less often misses breaches that should be claimed in-day.
Step 7: the conversation thread (/staff/queue/{id})#
Each ticket needs a single-page view with the original ticket, the reply thread, and a reply form. Two shortcodes plus a wrapper:
[gravity_table id="tickets"
filter="entry_id:{url:id}"
columns="subject,body,category,priority,status,assigned_agent,sla_due_at"
allowed_roles="support-agent,support-lead,administrator"
allow_edit="status,priority,assigned_agent,internal_notes"
per_page="1"
hide_pagination="true"]
## Conversation
[gravity_table id="replies"
filter="ticket_id:{url:id}"
columns="from_agent,body,is_internal,created"
allowed_roles="support-agent,support-lead,administrator"
allow_edit=""
sort="created:asc"
per_page="50"]
## Reply
[gravityform id="replies" title="false" field_values="ticket_id={url:id}&from_agent={current_user}"]
{url:id} is read from ?id=... in the URL. So /staff/queue/?id=1042 shows ticket 1042’s full thread. The field_values=... parameter on the GF shortcode pre-fills the hidden fields on the reply form.
Hide internal notes from the customer’s view#
The same conversation thread on a customer-facing URL must hide internal-flagged replies. Two changes:
- Add
filter="ticket_id:{url:id},is_internal:0"to the customer-page replies shortcode, the second condition narrows to public replies only - Replace
allowed_roles="support-agent,..."withallowed_roles="subscriber,customer,administrator"plus afilter_user_owns="customer_id"on the parent ticket query so customers can only see their own thread
Step 8: customer email notifications#
When an agent posts a public reply, email the customer. In Gravity Forms admin, on the replies form, add a Notification:
- Send to: dynamically populated from the parent ticket’s
customer_email(use a hook to look up the parent ticket) - Trigger: form submission, conditional on
is_internal != 1 - Subject:
Re: {Subject from ticket #{ticket_id}}
The Gravity Forms conditional logic runs server-side, so the customer never sees an internal note in their inbox.
What this gives you#
For a 1-5 agent team, this configuration delivers:
- No per-agent SaaS bill, agents are WP users, no extra cost
- One-click ticket-to-thread navigation, the table links each ticket id to its conversation view
- Live queue with SLA-breach visual cues
- Audit log of every status change, every reassignment, every priority bump
- Real exports, leadership sees the weekly volume by category in a real Excel file, not a screenshot
- Customer self-service, customers can browse their own ticket history without emailing for it
What it doesn’t give you:
- Built-in macros / canned responses (you can add a “templates” form and look up by category, but it’s not native)
- Time tracking per ticket (use a separate field if you need it)
- Multi-channel intake (chat, social, voicemail), this pattern is email + form intake only
For most teams who currently use a shared inbox or a $300/mo help-desk, this is a meaningful step up.
Recipe variations#
Internal-only IT helpdesk#
Drop customer_id and customer_email; replace with requester_user. Gate the submission form with a logged-in shortcode wrapper. Same internals, no external customer flow.
Agency-style client support#
Add a client_account field (lookup to a Gravity Forms list of client orgs). The agent queue groups by client; SLA timers vary by client tier (a sla_hours_override on the client account row, applied at ticket creation).
School pastoral / counseling#
status values become triage, assigned, in_progress, referral, closed. priority becomes risk_level. The internal-only thread becomes the case-notes audit. Privacy gates tighten, only the assigned counselor and the lead see a given case.
What to avoid#
Don’t put internal notes on the public ticket form#
A notes field on the customer submission form is one toggle away from being shown to the customer. Use the replies form’s is_internal flag exclusively for internal commentary, so the data model itself prevents the leak.
Don’t skip the SLA timer if you have one#
A “we promise to reply in 24 hours” claim with no system enforcement is a complaint waiting to happen. The breach alert is what turns SLAs from a marketing line into an operating one.
Don’t merge tickets and replies into one form#
Two forms looks like overhead but pays back immediately: each reply is now its own auditable entry, each is independently editable/deletable, and the conversation is queryable as data instead of buried in a long text field.
Related#
- Build a moderation queue, sister status-machine pattern (public submissions)
- Build a customer portal, the customer-self-service flip side
- Build a CRM lead dashboard, sales-team analogue with similar shape
- Role-based table permissions, the permissions model in depth
- Hooks → bulk action registration, for the assign / close custom actions