/admin/support and needs support.read to open. It
has two tabs: Queue, the ticket workspace, and Report, the workload and
response-time figures.
The queue is the primary view because that is the question an operator actually
arrives with — what is waiting, and who has it. A ticket opens beside the queue
rather than replacing it, so working through a backlog does not mean navigating
back after every reply.
A customer may hold several conversations at once. A billing question and a
connection problem are two conversations, and forcing them into one thread makes
both harder to answer and impossible to route separately. The bot still continues
the customer’s most recent live ticket — open, pending, or resolved, and active
in the last 72 hours — when they simply type a message, because that is what
they mean; the web panel always opens a new one from /account/support/new.
Queues carry the promise
A queue is a named bucket with a first-response target and a resolution target, both in seconds. The target lives on the queue rather than on the priority because they answer different questions: priority is how this ticket compares to the work in front of it, and the target is what the operator promised the customer. A billing queue may promise four hours for everything in it while a general queue promises two days. Exactly one queue is the default, enforced by a partial unique index, so a new ticket always has somewhere to go. Without one, creating a ticket refuses with 503support_unavailable and the customer’s question would land nowhere — the
API logs that as an installation fault rather than a customer error.
Every installation is seeded with a general queue named “General” in English and
“Общие вопросы” in Russian, default, sort order zero.
A target of zero means the queue makes no promise and never breaches. The
breach predicate requires a positive target, which is a legitimate configuration
for a queue that exists only to route.
Defining one
There is no form. SendPUT /v1/panel/support/queues with:
code is lower-cased and must match ^[a-z][a-z0-9_-]{1,63}$. The upsert is by
code. Setting isDefault: true clears the previous default in the same
transaction, so there is never a moment with two defaults or none. A missing name
in either language, or a negative target, is 422.
This route needs settings.write, not support.write. Queues, tags, and
canned replies are configuration rather than day-to-day support work: an operator
answering tickets should not be able to redefine the promises their queue makes.
Archiving a queue is not reachable. The guarded statement exists — it
refuses a queue that is the default or still holds open or pending tickets —
but no route or service method calls it. Re-saving an archived code clears its
archive stamp, so a queue can be brought back but not put away through the API.
Working the queue
Each queue renders as a card showing its English name, the open count, an amber “unassigned” badge, a red “breached” badge, and the first-response target as a duration. Those three counts are what an operator reads before choosing what to work on: how much is here, how much nobody owns, and how much is already past what the queue promised. Clicking a card filters the list to that queue. The cards refresh every 30 seconds and the ticket list every 15. The status filter offers all,open (the default), pending, resolved, and
closed. Tickets are ordered oldest activity first, on a keyset cursor over
(last_message_at, id) — the customer’s own inbox sorts the other way, newest
first, because they are answering a different question.
Each row shows the subject, an unread badge, a priority badge, an “overdue” badge
when the first-response target was breached, the assignee or “nobody”, the last
message date, and tag chips. The breach flag is computed in SQL, so “overdue”
means the same thing in the list, in the queue counters, and in the report.
The API also filters by
priority, assigneeId, unassigned=true,
customerId, and tag, and takes a pageSize up to 200 (the default is 25;
the panel asks for 50). Only the queue and status filters have controls in the
panel — the rest are available through
GET /v1/panel/support/tickets directly.Assignment
Take it in the conversation header assigns the ticket to the signed-in operator; the same button becomes Release once it is assigned. Releasing is a first-class action rather than an error state: an operator who cannot finish something should be able to put it down. The route isPOST /v1/panel/support/tickets/{ticketID}/assign with
{"assigneeId": "<uuid>"}; an empty string releases. It stamps assigned_at and
nulls it on release, and audits as panel.support_ticket.assigned or
panel.support_ticket.released.
Priority, queue, and tags
Priority islow, normal, high, or urgent, defaulting to normal, set
from a select in the header. It deliberately does not change the SLA target,
which belongs to the queue. Anything outside the four is 422. Customers see a
ticket’s priority but cannot set one.
Queue is a select that moves the ticket
(POST /v1/panel/support/tickets/{ticketID}/queue).
Tags are bilingual labels, filterable in the queue, which is categorisation an
operator can act on without inventing a second status vocabulary. The header’s tag
select lists every tag prefixed + to attach or − to detach, and the panel
issues PUT or DELETE on
/v1/panel/support/tickets/{ticketID}/tags/{tag} accordingly. The path parameter
is the tag code, not its id; an unknown code is 404 and attaching twice is a
no-op. Creating a tag is API-only: PUT /v1/panel/support/tags with
{code, nameEn, nameRu}, behind settings.write.
Every control above is hidden entirely when the ticket’s status is merged, and
every underlying statement carries AND status <> 'merged' so a direct API call
answers 404 rather than mutating an absorbed ticket.
Status, reopening, and the SLA timestamps
merged cannot be set through the status route, which is why merging is its own
action.
Timestamps are recorded as facts rather than computed, so “when was this first
answered” survives a queue’s target being changed afterwards:
first_response_atis written by the first operator reply and never moved, so a conversation that goes back and forth for a week keeps its original measure.- Setting
resolvedorclosedstampsresolved_atif it was not already set. - Moving back to
openorpendingwhenresolved_atwas set incrementsreopened_count— the signal that an answer did not actually answer the question.
reopened_count; a customer replying on a pending ticket returns it to open,
because the wait for them is over. The same rules apply in the bot and the web
panel, word for word.
The customer is told
Settingresolved or closed writes a system message into the conversation
— “Support marked this request as resolved. If this did not solve it, just
reply and the request reopens.” or “Support closed this request. You can reopen
it or start a new one.” — in the customer’s language, resolved from their bot
preference and then their account language at the moment it is written. It
counts as activity on the ticket, the web panel renders it as a system turn, the
bot shows it as ⚙️ System, and the bot’s support loop pushes it to Telegram
exactly as it pushes a reply, headed ⚙️ Request updated. Setting the same
status again is not a transition and writes nothing. A move to pending is not
announced: it is the desk’s own bookkeeping, and the operator’s reply that
normally accompanies it says what is needed.
An operator reply is activity too: it bumps last_message_at and updated_at
at write time, so a web-only customer’s answered ticket rises to the top of
their inbox without waiting for a push that will never come for them.
Customers get explicit Close and Reopen controls too
(POST /v1/account/support/tickets/{ticketID}/close and /reopen). Closing an
already-closed ticket succeeds and changes nothing — the customer asked for a
state, not for a transition. Closing or reopening a merged ticket is 422 with an
explanation that it was merged into another conversation.
Internal notes are not messages
A note is a separate table, not avisibility flag on support_messages.
That is deliberate and it is the whole safety property. Every path that delivers a
message to a customer reads support_messages; a flag on that table would mean
every one of those paths has to remember to check it, and the first one that
forgets sends an operator’s private note to the customer it is about. A note
cannot be delivered because it is not in the table the delivery path reads. No
query in the customer-facing package mentions the notes table at all.
The conversation screen has two boxes with two buttons: a reply textarea with
Send, and a dashed, amber-bordered note textarea with Add note. They are
deliberately not one box with a toggle, because a toggle is a state somebody can
be wrong about, and being wrong here means sending the customer a note about them.
Notes render inline in the timeline on a dashed warning surface with an “internal
note” label, and the ticket detail response returns messages and notes as two
separate arrays so no client can mistake one for the other. Each is capped at
200 entries.
POST /v1/panel/support/tickets/{ticketID}/notes takes {"body": "..."}, 1–4000
characters, and requires an author who is a real admin user. It audits as
panel.support_ticket.noted with no metadata at all — the note body is never
copied into the audit trail.
Replies are written here and delivered by the bot
The panel writes the reply; the bot’s notifier delivers it. That keeps consent, quiet hours, Telegram delivery health, and blocked-account handling in one place instead of duplicated into the panel.1
Insert a canned reply, optionally
The Insert canned reply select fills the reply box with the response’s
body, and using one increments its usage counter.
2
Write and send
POST /v1/panel/support/tickets/{ticketID}/reply with {"body": "…"} and a
required Idempotency-Key header. A request without one is 422
idempotency_key_required.3
Watch it flip from queued to delivered
The message appears immediately labelled
queued. The bot’s support-reply
loop runs every 30 seconds — on its own, independent of the 15-minute
lifecycle pass — claims up to 200 undelivered operator and system messages
in order, sends them, and only then stamps delivered_at, at which point
the panel shows delivered and the customer’s unread counter goes up
exactly once.What the delivery label means
Every push leaves a row innotification_deliveries with kind support, keyed
support-message:<id>, and the desk reads that row back beside the message.
The customer’s own notification history shows the same row, so “did you send
it” has one answer on both sides.
An undeliverable or failed message is not lost. It stays in the conversation,
unread, and the customer sees it when they open
/account/support. The label
exists so an operator knows to say so rather than waiting for a push that is not
coming — and so that one customer who blocked the bot cannot hold the head of
the queue: a message in a terminal state is excluded from the next claim, and
the replies behind it go out.
The idempotency key becomes the message’s dedupe key, unique per ticket, so a
resubmitted form reaches the message that already exists rather than sending the
customer the same answer twice. A repeated key returns 200 with an empty message
object rather than an error.
The first response is recorded in the same transaction as the message, so the SLA
measure and the reply commit together — one cannot exist without the other.
The audit record, panel.support_ticket.replied, carries only the canned response
id. The body is deliberately absent: the message itself is the record, and copying
it into the audit trail would duplicate customer content into a second retention
regime.
Canned responses
A canned response is a reusable reply in both Russian and English, declaring the permission needed to send it, with a usage counter. Refund wording and policy statements are written once and reused; an operator rewriting them in the moment is how two customers get told different things. Both languages are mandatory because the language of the customer is not the language of the operator. A response the signed-in operator may not use is filtered out of the list server-side rather than shown disabled: a list of replies somebody cannot send is a list they have to read past every time. The list is ordered by usage, most-used first. Authoring is API-only:PUT /v1/panel/support/canned with
{code, titleEn, titleRu, bodyEn, bodyRu, requiresPermission} behind
settings.write. DELETE /v1/panel/support/canned/{responseID} archives rather
than deletes, so the record of what was sent with it survives; archiving an
already-archived response is 404.
Two rough edges to know about.
requiresPermission defaults to support.write
and is stored as free text — it is not validated against the real permission
list, so a typo silently hides the response from everybody. And the panel
inserts the English body regardless of the customer’s locale, so an operator
answering a Russian-speaking customer must paste the Russian body themselves.Merging duplicates
Merging folds one ticket into another: the absorbed ticket’s messages move to the survivor, and the absorbed row survives with statusmerged and a pointer to
where it went. Deleting it would lose the customer’s own words and the trail that
explains where they went, and the customer’s bookmarked link still resolves —
the response carries the survivor’s id so the panel can link on.
Paste the survivor’s UUID into Merge into in the conversation header and press
Merge, or call
POST /v1/panel/support/tickets/{ticketID}/merge with {"survivorId": "<uuid>"}.
Both rows are locked before anything moves. The refusals:
- A merge across customers is refused with 422. Putting one customer’s words into another’s conversation is not a mistake worth warning about afterwards.
- Merging a ticket into itself is refused, both in Go and by a database
CHECK. - A ticket already
mergedcannot be merged again. - Merging into a ticket that was itself merged is refused with 409
state_conflict; the survivor must be a live conversation.
resolved_at, its unread counters are zeroed,
and it accepts no further customer reply. What it was carrying moves to the
survivor: both unread counts are added on, the later last_message_at wins,
and a survivor that was resolved or closed while the absorbed ticket was
still open or pending becomes open again (counting as a reopen), so a
live question cannot be merged into a finished thread and vanish from the
queue. A system message on the survivor tells the customer that their other
request — named by its subject — was merged into this conversation. Replying to
the absorbed ticket from the desk is refused with 409 state_conflict, because
nobody reads it any more. The action audits as panel.support_ticket.merged
with the survivor’s id.
Attachments
A customer can attach a file from either surface, and the two have different limits, which is worth knowing when a ticket carries something the web panel would have refused.What the operator sees
Every message inGET /v1/panel/support/tickets/{ticketID} carries an
attachments array: id, kind (photo or document), file name, media type,
size, origin, and downloadable. origin is web for a file this
installation holds and telegram for a reference to a file in the customer’s
chat. The conversation screen lists each file under its message with its size.
A web upload has a Download button behind
GET /v1/panel/support/tickets/{ticketID}/attachments/{attachmentID}, which
needs support.read — an operator entitled to read the words is entitled to the
screenshot that came with them. The file is scoped to its ticket in the query,
so swapping identifiers cannot reach another conversation’s file. It is served
with Content-Disposition: attachment, nosniff, and the stored media type
only while that type is still on the allowlist; a file whose bytes were purged
answers 404, and a process with no attachment directory answers 503
attachment_storage_unavailable. The panel fetches it through the API and
hands the bytes to the browser, so a refusal is shown as a message rather than
saved to disk as a JSON file named like the attachment.
A Telegram file is labelled in Telegram and has no button. The route answers
409 attachment_remote for it and never fetches anything: Omniflow holds a
reference, not the bytes, and proxying Telegram’s file API with the bot token on
an operator’s behalf would put a customer’s file behind a credential the panel
was never meant to hold. Ask the customer to re-send the file through the web
panel if an operator needs it outside the chat.
The customer panel reads its limits from GET /v1/account/support/limits before
the form is usable, so the character counters and the size hint match exactly what
the API refuses at. None of these limits are configurable — only the storage
directory is.
Uploads are refused precisely rather than defensively. An over-size body is 413
attachment_too_large, bounded before parsing rather than after. A declared
content type outside the allowlist — or missing, or unparsable — is 415
attachment_media_type, with no sniffing, because a guess is precisely what an
allowlist must not accept. An empty file is 422. Uploading to a closed or merged
ticket is 409; uploading to a resolved one reopens it, exactly like a reply — and
like a reply, it is refused with 409 too_many_open_tickets when reopening would
take an open slot the customer does not have. A conversation already carrying
20 web uploads (maxAttachmentsPerTicket in /v1/account/support/limits)
answers 409 too_many_attachments; the count is taken under the ticket’s row
lock so two uploads racing at the limit cannot both pass it.
An upload takes the same optional Idempotency-Key header a reply does. The key
becomes the message’s dedupe key, and a replay finds both the message and the
file it already hung on it — one message, one file, however many times the form
was submitted. The panel derives the key from the file’s name, size, and
modification time plus the note, so a double-tapped Send is one upload and a
different file is another.
The three customer writes are rate-limited per customer in the API, sharing the
privacy surface’s limiter: 10 new conversations, 60 messages, and 20 uploads per
hour. Past a budget the route answers 429 rate_limited, and an upload is
refused before its body is read. A limiter outage fails closed, as it does for a
data export.
Ownership is checked before any bytes are written and again under a row lock
inside the transaction, so a stranger cannot spend the installation’s disk on a
ticket they cannot see. Bytes are written before the row, so a row can never point
at a missing file.
Attachments are retained for 90 days, which is a column default and not
configurable. The retention sweep and the directory both matter operationally —
see News and customer communication for the
directory setting and why the API and worker must agree on it.
The workload and response-time report
/admin/support → Report. Window buttons offer 7, 30, and 90 days; the default
is 7 and the API caps it at 90, so one request cannot ask the database to compute
a median across every ticket ever filed.
Each definition sits under the figure it defines, on the tile itself. A
response-time report whose definition is ambiguous is a report people argue
about instead of acting on: “average response time” means at least three
different things depending on who is asked, and none of them is what this
measures.
They used to be a list at the foot of the page headed What these numbers mean
— six paragraphs at the furthest point from the numbers they explained, and in
English whatever language the operator had chosen, because the API supplied the
prose. The copy now lives in the panel’s message catalogues like every other
operator-facing string, and the payload carries only the figures.
Below the five headline figures sit replies per operator, drawn as a horizontal
bar chart with the full table under it: display name, replies, open tickets,
resolved tickets, and that operator’s median first response. The bars are
horizontal because the categories are people’s names, which a vertical chart
would truncate or tilt; the table is always rendered rather than hidden behind a
disclosure, because a chart is not a substitute for a number somebody has to act
on. Two things about the table are deliberate. Per-operator open tickets are not windowed —
they are what that person is currently holding, which is a present-tense question.
And replies counts messages the operator wrote regardless of who the ticket
was assigned to, because helping with somebody else’s ticket is work.
Operators are listed from the active admin users, ordered by display name, so
somebody with no activity in the window still appears with zeroes rather than
vanishing.
Operator unread counts
operator_unread_count rises with every customer message on either surface and
with every customer reopen, so a queue row can show what has arrived since anybody
looked. A replayed customer submission carrying the same idempotency key does not
raise it, because the increment only runs when a row was actually inserted.
The badge does not clear when an operator opens the ticket.
POST /v1/panel/support/tickets/{ticketID}/read exists and clears the counter,
but the panel never calls it, so the count only falls if something outside the
panel does.Permissions
Reading a ticket and answering one are separate because they are separate jobs: a
finance operator investigating a refund needs to read the conversation that led to
it, and should not be able to reply to the customer in the desk’s voice.
Every mutation is audited:
panel.support_ticket.assigned, .released, .moved,
.prioritised, .status_changed, .replied, .noted, .merged, plus
panel.support_queue.saved, panel.support_tag.saved,
panel.canned_response.saved, and .archived.
What a customer is limited to
A sixth open ticket is 409
too_many_open_tickets with the number in the detail.
The quota exists so a loop in a client cannot fill the operator queue faster than
people can empty it. A resolved ticket is not open and does not consume a
slot, and it can still be replied to — which reopens it, and therefore takes a
slot: the reply, an upload, and the explicit Reopen are all refused with the
same 409 when the customer already holds the maximum. Without that, the quota
was a rule about creating tickets and nothing else.
Counting is in runes rather than bytes, so a Russian-language message is not
penalised for its encoding. Both open and canReply are computed server-side
rather than derived from the status string by the client, so the quota rule and
the composer rule cannot diverge.
Related
Customer web panel
/account/support from the customer’s side.Telegram bot
/support, the compose flow, and attachment handling in chat.News and customer communication
Delivery classes, attachment retention, and the storage directory.
Admin panel
Roles, sessions, and the rest of
/admin.