Support
Run a helpdesk on your own site: a ticket portal your customers use, an agent inbox in the admin, and replies that arrive without a refresh.
Support turns your WordPress site into a helpdesk. Customers open tickets on a page you build, read the thread and reply. Your team works the same tickets from wp-admin, with internal notes the customer never sees, statuses you name yourself, and the customer's plan and licences beside the conversation.
Both screens keep themselves current. With no third-party account they check for new activity every five seconds. With Realtime configured for Pusher or PubNub they update over a socket in about a tenth of a second, and the timer stops.
Where to find it
Two places. The customer's half is the Support Portal block, which you put on a page. Your half is XPAC → Support in wp-admin, which appears for anyone whose role holds the agent capability. Everything site-wide lives in XPAC → Support → Settings.
Quick start
Open XPAC → Support → Settings → Portal and choose Who can open a ticket. Signed in is the safe default. Anyone makes the composer on that page a public write endpoint; read Before you launch first.
Under Email, set the From name and From address, or leave them empty to use your site title and WordPress's default address. Support sends through WordPress mail, so an SMTP plugin your site already uses delivers it too.
Give your team the agent capability. Any role holding xpac_support_respond sees the
inbox; by default that is anyone who can manage options.
The two surfaces
| Customer portal | Agent inbox | |
|---|---|---|
| Where | A page on your site carrying the Support Portal block | XPAC → Support in wp-admin |
| Who | Signed-in customers, and guests if you allow them | Roles holding xpac_support_respond |
| Sees | Their own tickets, public replies only | Every ticket in scope, replies and internal notes |
| Can | Open a ticket, reply, attach files, close their own | Reply, note, set status, priority, product, close, reopen, delete |
| Needs | Nothing beyond the block | The capability, and the inbox menu appears |
The portal is one block used three ways. Its mode attribute is full for the list and
thread, and compose for a bare "open a ticket" form you can drop on a contact page. The
same block also mounts inside the account area of a site running the XPAC account plugin,
where the navigation shows an unread count beside it.
Tickets
A ticket is a subject, a first message and everything that happened afterwards.
| Field | What it is |
|---|---|
| Reference | A twelve-character public id like A7K2M9QX3B4C. Customers quote it; searching for it is the fastest lookup in the product |
| Subject | The customer's own words. Searchable |
| State | What the machine reasons about: open, pending, on hold, resolved, closed, spam, trash |
| Status | Your own label on top of a state, so "pending" can read "Waiting on customer" without a database change |
| Priority | Your own list. Ordering only; it drives no automatic behaviour on its own |
| Product | Which of your things this is about. Optional, and you define the list |
| Requester | The person who opened it — an account, or an email address for a guest |
| Participants | The requester plus anyone CC'd. A participant sees the ticket; nobody else does |
Replies come in two kinds and one axis crosses both. A reply is part of the conversation; a note is for your team. A system entry is written by the plugin itself — "Resolved by Ani", "Dana assigned this to Miles" — and it can be public or internal too. All four combinations are real, which is why visibility and kind are stored separately.
Attachments are stored outside the media library and served through a route that checks access on every single request, so a file's URL is not a way around permissions. Size, count per reply and permitted MIME types are all settings.
Who can see what
Three questions get asked on the server on every read, every write and every live update, and the answer to all three comes from one place:
- Is this person an agent? A capability check, filterable, never a role name.
- May they see this ticket? A requester is a row in the participants table. An agent is the capability, and an agent sees every ticket on the site.
- May they see this reply? Public replies for everyone on the ticket; internal notes for agents only.
An internal note is not filtered out of a customer's page — it never reaches it
Notes are stored with a different visibility value, counted by a different counter and published on a channel the customer cannot subscribe to. The customer is not shown the note, and is not told that a note exists: the number their browser polls does not move when an agent writes one.
Search obeys the same rule, and it matters more than it looks. A customer searching their own thread never matches text inside an internal note — not even to change the number of results — because the visibility ceiling is applied inside the search, not after it.
Live updates
Three tiers, and every install gets one of them:
| No Realtime plugin | Realtime, polling provider | Realtime, Pusher or PubNub | |
|---|---|---|---|
| How it stays current | Support's own counter route | Realtime's batched counter route | A socket |
| Latency | 5–15s, whatever the interval says | 5–15s | About 100ms |
| Requests per tick, per browser | 1 | 1, shared with every other XPAC feature on the page | 0 |
| Hidden tabs | No | Inbox only, at a quarter rate | Yes, everywhere |
| Typing indicator | — | — | Yes |
| "Who else is viewing this" | — | — | Yes, agents only |
| Third-party account | None | None | Pusher or PubNub |
Polling is not a fallback. It is the tier most sites run, and the one every install gets without signing up for anything. The browser asks a twenty-byte question — "has forty-one become forty-two" — rather than reloading anything, and it reacts once per change no matter how many things changed. Six replies inside one five-second window produce one refresh, not six.
The floor is five seconds and it is enforced in three places, so no setting can go under it. An agent inbox with a ticket open costs one request and about two database queries every five seconds from one browser, however long the ticket list is: the list rides one counter for the whole site rather than one per ticket.
That has an aggregate you should size before you promise it. Twenty agents plus a hundred customers with the portal open, all at the five-second floor, is roughly 24 requests a second reaching WordPress. On a shared host that is real load. XPAC → Support → Settings → Live prints your own site's figure from your agent count and your interval; raise the interval to fifteen seconds and it drops to a third.
A dead socket falls back, it does not go quiet. Sockets get refused for ordinary reasons — an agent whose capability was taken away, an expired page nonce on an inbox left open across a shift, a security plugin blocking a REST request. Every screen carries one chip that tells you which tier you are on and whether it is working:
- Live — a filled dot. A socket is carrying.
- Checked 4s ago — a hollow ring, counting up. Polling, and the last check succeeded.
- Not updating — Reload — amber. Three failures in a row, or a refused request.
The chip is a button; clicking it forces a check. The third state exists because the alternative ships: a screen that looks perfectly alive and has been dead since lunchtime.
New activity arrives as a New reply · Show bar rather than appearing under your cursor, except in a thread you are already reading at the bottom of a focused tab. Sending is never gated on any of this — your own message posts over REST and appears immediately.
Where tickets come from
| Route | Needs | Notes |
|---|---|---|
| The portal composer | The block on a page | The normal path |
| A form | XPAC Forms active | Map an existing form's fields to a ticket. The entry is stored first, then the ticket opens carrying its id |
| An agent | The inbox | Open a ticket on a customer's behalf |
The Forms route is worth knowing about even if you already have a portal page: an existing contact form becomes a ticket source by mapping four fields, and the person filling it in sees no change at all.
Notifications
Support sends five emails. Each one has its own switch in Settings → Email, and all five are on by default.
| Goes to | |
|---|---|
| Ticket opened | The customer: the requester and anyone copied in on the ticket |
| Agent replied | The customer |
| Status changed | The customer |
| New ticket | Agents |
| Customer replied | Agents |
Agent emails go to the ticket's assigned agent, or to every agent while nobody is assigned. Whoever caused an event is not emailed about it; the one exception is the customer's own "ticket opened" confirmation.
An internal note never sends email, and neither does anything on a ticket marked spam or moved to the trash.
Mail is sent with wp_mail, like Forms and the rest of XPAC, so delivery is whatever your
site already uses — for example an SMTP plugin such as WP Mail SMTP. Sending runs in the
background on Action Scheduler, so a slow mail server never slows a reply down; on a site with
no queue it sends during the request. A send that fails shows up in Action Scheduler's log
with the reason wp_mail gave.
Every email has an HTML and a plain-text version, carries the ticket reference in its subject and threads by ticket in mail clients. Replying to an email does not add to the ticket, and the email says so: customers answer through the link to the ticket.
What you get
Support is a Pro plugin in its entirety. It is not sold a feature at a time and there is no free edition to compare it against: everything on this page — portal, inbox, replies, internal notes, attachments, statuses, priorities, products, guest tickets, live updates, assignment, canned replies, email notifications, and the privacy export, erasure and retention tools — is what the plugin is.
It is distributed to subscribers from xpac.io rather than published on wordpress.org, so you install it from a zip and it updates through the XPAC updater.
Support contains no licensing code
There is no licence check anywhere in this plugin, no gated feature, no 402, and no screen that asks you for a key. Install it on a site with nothing else from XPAC and every feature described here works. Licensing is an integration, not a layer: the XPAC account plugin supplies the licence screen, the updater and the telemetry, and Support only notices it to the extent of putting a Support section in the account area and showing the customer's plan beside their ticket. Remove that plugin and Support keeps working; nothing is disabled, expired or withheld.
Assignment
A ticket carries one assignee, or none.
Every agent sees every ticket. There is no scope below the capability: anyone holding
xpac_support_respond can list, open and answer any ticket on the site. The list query and
the live-update channel apply that same rule, so the REST API never shows an agent a ticket
the inbox would hide, or the other way round.
The inbox offers Mine and Unassigned alongside its status views, plus bulk assignment from the list. Assignment is enforced twice on the server — on its own route and on the general ticket update route — because a gate on one route with the field still accepted on another is not a gate.
Canned replies
Named, reusable answers with placeholders for the customer's name, the ticket reference and the site name. Insert one from the composer, edit it before sending — nothing is sent as-is unless you send it as-is.
A shared reply is stored per site and offered to every agent, so an answer one agent writes well is the answer everybody sends. An agent can also keep personal replies that only they see.
Settings
XPAC → Support → Settings.
| Group | What is in it |
|---|---|
| Portal | The portal page, who may open a ticket, guest tickets, how a guest claims a ticket after signing up, how long a guest session lasts |
| Tickets | Your statuses, priorities and products |
| Uploads | Maximum size, maximum per reply, permitted MIME types |
| Live | The check interval, floored at five seconds, and this site's computed request rate |
| From name (empty uses the site title), From address (empty leaves it to WordPress or your SMTP plugin), and a switch for each of the five emails | |
| Data | Retention, what retention applies to, whether uninstalling removes the tables, how long the audit log is kept |
| Spam | Captcha provider, and the honeypot and timing floor for guest submissions |
Before you launch
Anyone-can-open-a-ticket is a public write endpoint
With Who can open a ticket set to Anyone, every page carrying the Support Portal block accepts writes from the internet. That is the correct setting for a shop whose buyers are not signed in, and the wrong one for most other sites. If you turn it on, turn on a captcha in Settings → Spam in the same sitting, and watch the first week.
Four more things to do before you send the page to customers:
Make sure mail arrives. Support sends through WordPress mail, so it is only as reliable as your site's mail. Many hosts deliver little or nothing without help, so install an SMTP plugin such as WP Mail SMTP if you do not have one. Then open a ticket as a customer, reply as an agent, and confirm each email arrives in an inbox rather than a spam folder.
Decide the attachment policy. The defaults are conservative — a short MIME allowlist and a few megabytes. Widen them deliberately.
Attachments are not scanned for malware
Uploads are limited by size, count and MIME type, stored outside the media library and served through an access-checked route. Nothing scans their contents. An agent opening a customer's attachment is opening a file from a stranger, and the plugin cannot make that safe.
Size the polling load. Read the number Settings → Live prints. If it is uncomfortable for your host, raise the interval before you have agents rather than after.
Say what you keep. Settings → Data sets retention. The privacy tools are wired up — Support answers WordPress's own export and erasure requests — but erasure destroys the requester's own reply bodies irreversibly, so the site owner's bulk export exists to keep a copy first.
For developers
Storage
Six tables, all prefixed wp_xp_support_: tickets, ticket_replies,
ticket_participants, ticket_attachments, ticket_events and canned_replies. They are
created and migrated by the plugin's own runner rather than by dbDelta guesswork. The
current schema is 1.8.0; on an older site, M008_SimplifyMail removes the old mail system's
tables, columns and settings, and M009_RemoveTags removes the ticket tag tables.
wp_xp_support_tickets is the hot one: twelve secondary keys, chosen so that every inbox view
is an index range with the ordering already in the key and no filesort. Search is InnoDB
FULLTEXT over the subject and the reply body, with a bounded LIKE fallback when the index
could not be created and a straight unique-index hit when the term looks like a reference.
Nothing references wp_users with a foreign key. A ticket keeps the author's name and email
as a snapshot written at insert, so deleting a WordPress account does not turn a thread into
a wall of "user #0".
Blocks and assets
One block, xp/support-portal. Its front-end app is a Vite build declared as
viewScriptModule, so WordPress registers and enqueues it as an ES module whenever the block
renders. React is externalised to the shared XpacReact runtime the plugin ships. The agent
inbox is a separate wp-scripts build on @wordpress/element, and the two share one
TypeScript source file for the channel names, the event union and the liveness hook.
Request path
GET /wp-json/xpac/v1/support/tickets
POST /wp-json/xpac/v1/support/tickets
GET /wp-json/xpac/v1/support/tickets/<id>
POST /wp-json/xpac/v1/support/tickets/<id>/replies
GET /wp-json/xpac/v1/support/attachments/<id>
GET /wp-json/xpac/v1/support/revision?ticket=<id>
POST /wp-json/xpac/v1/support/revisions
GET /wp-json/xpac/v1/support/admin/tickets
PATCH /wp-json/xpac/v1/support/admin/tickets/<id>
POST /wp-json/xpac/v1/support/admin/tickets/<id>/repliesThe portal routes and the agent routes are two controllers on purpose: the portal's response schema has no visibility field at all, so an internal note has nowhere to leak to even if a query forgets.
Channels
Seven namespaces, registered in one place:
| Namespace | Keyed on | Audience |
|---|---|---|
xp-support-ticket | ticket id | Requester, CCs and permitted agents |
xp-support-ticket-staff | ticket id | Agents only |
xp-support-typing | ticket id | Same as the ticket channel |
xp-support-viewing | ticket id | Agents only, presence |
xp-support-inbox | site id | Every agent on the site |
xp-support-customer | user id | That one customer |
xp-support-agent | user id | That one agent |
What travels is a signal — an event name, a role, an integer id and a timestamp, about ninety bytes. Never a subject, never a body, never a name. When sockets are on, every byte of this crosses a third party's infrastructure, and a support ticket is the richest personal data a WordPress site holds.
Each email is rendered from an HTML and a plain-text template, placed inside a shared wrapper.
The templates live in packages/Support/Templates/Notifications/:
| Templates | |
|---|---|
| Ticket opened (customer) | ticket-created-html, ticket-created-text |
| Agent replied | agent-replied-html, agent-replied-text |
| Status changed | status-changed-html, status-changed-text |
| New ticket (agents) | new-ticket-html, new-ticket-text |
| Customer replied | customer-replied-html, customer-replied-text |
| Around every email | wrapper-html, wrapper-text |
A theme can override any of them with a file at xpac-support/notifications/<name>.php.
Sending runs on the Action Scheduler hook xpac_support_send_notification, in the
xpac-support group. The $event these filters receive is one of ticket_created,
agent_replied, status_changed, new_ticket or customer_replied.
| Filter | Arguments | What it controls |
|---|---|---|
xpac_support_notification_enabled | bool $enabled, string $event, int $ticketId, array $context | Whether one email is sent. It is only asked when that email's switch is on, so it can turn an email off but never on |
xpac_support_notification_recipients | array $recipients, string $event, array $ticket | Who the email goes to. Each entry has email, name and user_id; an agent email never reaches the requester |
xpac_support_notification_email | array $context, string $event, array $ticket, array $recipient | The values the templates are rendered with |
xpac_support_notification_template | string $path, string $name, array $context | The file a template is rendered from. It wins over both the theme and the packaged copy |
Hooks
Lifecycle (xpac_support_init), ticket lifecycle (xpac_support_ticket_created,
_ticket_status_changed, _reply_created, _note_created), policy (xpac_support_is_agent,
_can_view_ticket, _can_reply_to_ticket, _ticket_statuses, _login_url), intake
(_intake_ticket_args, _requester_for_submission), email (_notification_enabled,
_notification_recipients, _notification_email, _notification_template), the
account-area integration (xpac_support_customer_context and the xpac_account_sections
filter it registers on), and the portal block (xpac_support_portal_agent_name). In
the browser, @wordpress/hooks filters xpac-support-inbox-columns,
xpac-support-ticket-panels, xpac-support-reply-toolbar and
xpac-support-portal-ticket-actions.
The portal filter is the one a site's own glue plugin is most likely to need:
| Filter | Arguments | What it controls |
|---|---|---|
xpac_support_portal_agent_name | string $name, int $userId | The name customers see on an agent's replies and in the "Name:" prefix of a ticket's last-reply line. The default is the agent's first name, then their display name when it differs from their login, then empty; an empty name reads as "Support team" |
See the Support hook reference for every hook with its signature and call site.