Skip to main content
The support desk lives at /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 503 support_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. Send PUT /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 is POST /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 is low, 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_at is 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 resolved or closed stamps resolved_at if it was not already set.
  • Moving back to open or pending when resolved_at was set increments reopened_count — the signal that an answer did not actually answer the question.
A customer replying on a resolved ticket auto-reopens it and bumps 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

Setting resolved 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 a visibility 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 in notification_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.
A customer with no Telegram identity is never pushed a reply. Someone who signed in by magic link or OIDC sees the reply when they open /account/support; the loop records the message as undeliverable with reason no_telegram and sends nothing anywhere. Support replies still bypass the consent, quiet-hours, and frequency-cap checks — a reply is something the customer asked for — but they do write a delivery row, which is where the retry count and the undeliverable reason live.The customer is told which case they are in. GET /v1/account/support/tickets carries telegramLinked, true when the account has an active Telegram identity or the imported Remnawave mapping the delivery query also accepts, and the panel words its support page from it: “answered here and in Telegram” for a linked account, “answered here” with a hint to open the bot for one that is not.

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 status merged 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 merged cannot be merged again.
  • Merging into a ticket that was itself merged is refused with 409 state_conflict; the survivor must be a live conversation.
The absorbed ticket is stamped 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 in GET /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.

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.