Skip to content
Gravity Tables
tutorial

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.

· 8 min read · By Fahd Murtaza

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 text
  • body, paragraph, rich-text on
  • category, dropdown (Billing, Technical, Account, Feature request, Other)
  • customer_email, email (auto-populated for logged-in users)

Hidden admin-side fields:

  • status, Hidden, default open. Values: open, pending_customer, pending_agent, resolved, closed
  • priority, Hidden, default normal. Values: low, normal, high, urgent
  • assigned_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 page
  • body, Paragraph
  • is_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 tickets
  • allow_edit="", customer view is read-only; they submit edits as new replies, not by editing the ticket directly
  • allowed_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=42 URL params, otherwise administrator overrides 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_agent only editable by support-lead, agents work their assigned tickets; only leads reassign
  • auto_refresh every 30s, a new ticket appears in the queue without manual refresh
  • Bulk assign / close / export, register assign and close via the gt_register_bulk_actions filter (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&gt_filter_status=open,pending_customer">My open</a>
  <a href="/staff/queue?gt_filter_status=open&gt_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&gt_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:

  1. Add filter="ticket_id:{url:id},is_internal:0" to the customer-page replies shortcode, the second condition narrows to public replies only
  2. Replace allowed_roles="support-agent,..." with allowed_roles="subscriber,customer,administrator" plus a filter_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.