Support
List My Support Tickets
Lists support tickets across every organization the caller may see, paginated, enriched with a last-message preview, unread count, and review status (design doc ยง44.2 -- an organization is a shared workspace, so members see each other's tickets, not only ones they personally opened). Every row carries its own organization_id, so the listing can be grouped by owner; for one organization's tickets alone, read /organizations/{id}/tickets instead.
query Parameters
pagePage number (1-indexed). Defaults to 1.
Page number (1-indexed). Defaults to 1.
page_sizeNumber of items per page. Defaults to 20, max 100.
Number of items per page. Defaults to 20, max 100.
List My Support Tickets โบ Responses
Tickets
Pagination metadata included in paginated responses
Get My Support Ticket
Gets one support ticket with its messages, from any organization the caller may see (design doc ยง44.2 -- an organization is a shared workspace, so members see each other's tickets and message threads). Deleted messages are omitted; staff authorship is never exposed (shown only as "Support Team").
path Parameters
idGet My Support Ticket โบ Responses
The ticket
One ticket visible to the caller, in the detail view (paired with its messages in
TicketDetailResponse) -- any ticket belonging to any organization the caller may see
(design doc ยง44.2), not only ones the caller personally opened. Same no-staff-identity
rule as TicketSummaryResponse.
Reply to Support Ticket
Posts a reply into a support ticket owned by the caller or by another member of an organization the caller belongs to (design doc ยง44.2's shared-workspace model; owner ruling 2026-09-22 / ZC-0424 -- an organization member may reply to a colleague's ticket, not only read it). Staff and a reseller's manager (while their tier holds the ticket) may also reply, from the support side. Answers 404 for an organization the caller does not belong to and for an invented ticket id alike. A real ticket the caller may read but has no right to post into (neither a shared organization membership nor a support-side door) answers 403 rather than 404, since the caller already knows it exists. Answers a business-rule error for a closed ticket. Files uploaded against this ticket through POST .../attachments are carried by listing them in attachments; each is re-validated against the asset it names, and an id that does not resolve to an unsent attachment of this ticket refuses the whole message.
path Parameters
idReply to Support Ticket โบ Request Body
bodyFiles uploaded against this ticket that this message carries. Empty unless the caller uploaded something first; an attachment that is uploaded and never sent stays unreferenced and is not rendered anywhere.
Reply to Support Ticket โบ Responses
The new message
author_is_callerWhether the caller wrote this message.
ZC-1110. A ticket thread is visible to the whole organization
(design doc 44.2), so author_name alone cannot answer this: two
colleagues can share a display name, and offering edit or retract on
the wrong bubble is worse than offering neither.
Stated as a boolean rather than by publishing the author's id on
purpose. The id would answer this question and one nobody asked --
which member of staff wrote a staff reply -- and this module
deliberately shows staff only as "Support Team". A boolean cannot leak
that however it is later refactored; an Option<Uuid> could, by being
populated in one branch somebody forgot to guard. It is always false
for a staff message.
author_is_staffauthor_namebodycan_editWhether the caller may correct this message right now.
The server's own rule, not a hint to re-derive. EditSupportMessage
refuses a non-author with Forbidden and refuses ANY edit on a closed
ticket with TicketClosed, so this is both conditions already
resolved. A client computing author_is_caller && !closed for itself
would be deciding an entitlement, which is the boundary every frontend
in this program is told not to cross, and it would silently drift the
day kernel adds a third condition.
can_retractWhether the caller may retract this message right now.
Separate from can_edit because the rules genuinely
differ: retraction stays available on a CLOSED ticket, where editing
does not. Somebody who posted something they regret can always take it
back; rewriting history after the conversation ended is what the
closed-ticket rule exists to stop. Collapsing these into one flag
would quietly pick one of those two behaviours for both.
created_atidkindauthor_avatar_asset_idThe customer author's avatar (V-0880), as an asset id the client
resolves through the asset route. None for every staff message
(staff are shown only as "Support Team") and for a customer with no
avatar; the client falls back to a monogram.
edited_atSubmit Support Ticket Review
Submits the caller's own CSAT review for one of their closed support tickets, from any organization the caller may see. Answers 404 for an organization the caller does not belong to and for an invented ticket id alike. A real ticket the caller may read but does not own (another organization member's, under design doc ยง44.2's shared-workspace read model) answers 403 rather than 404, since the caller already knows it exists -- reviewing is owner-only, narrower than the read model above it. Answers a business-rule error for a ticket that is not yet closed, and resource-already-exists for a ticket that already carries a review: a review is immutable once submitted, never replaced by a second call. response_quality and resolution_speed must each be between 1 and 5.
path Parameters
idSubmit Support Ticket Review โบ Request Body
problem_solvedresolution_speedresponse_qualitycommentSubmit Support Ticket Review โบ Responses
The submitted review
problem_solvedresolution_speedresponse_qualitysubmitted_atticket_idcommentList Organization Support Tickets
Lists one organization's support tickets, paginated, enriched with a last-message preview, unread count, and review status. An organization is a shared workspace, so every member sees the organization's tickets, not only ones they personally opened (design doc ยง44.2). Answers 404 for an organization the caller does not belong to, the same way reading that organization directly would.
path Parameters
idquery Parameters
pagePage number (1-indexed). Defaults to 1.
Page number (1-indexed). Defaults to 1.
page_sizeNumber of items per page. Defaults to 20, max 100.
Number of items per page. Defaults to 20, max 100.
List Organization Support Tickets โบ Responses
Tickets
Pagination metadata included in paginated responses
Create Support Ticket
Opens a new support ticket, owned by the caller, under one organization the caller belongs to, with the given subject and opening message. Answers 404 for an organization the caller does not belong to, the same way reading that organization directly would.
path Parameters
idCreate Support Ticket โบ Request Body
bodysubjectCreate Support Ticket โบ Responses
The new ticket
One message in TicketDetailResponse::messages.
Soft-deleted messages never appear here at all -- this endpoint reads
SupportMessageRepository::find_by_ticket_id_visible, which already
excludes them, rather than /v1's find_by_ticket_id (every message,
with the body blanked for a deleted one at render time). A /v2 read
surface has no reason to reconstruct a "[deleted]" placeholder in the
thread; see this crate's report.
A staff author's identity is never exposed: author_is_staff is true
and author_name reads "Support Team". A non-staff message's author_name is that
message's own author's display name, resolved per message by the caller of
from_message -- not necessarily the caller of the request. A ticket's whole thread is
now visible to every member of its organization (design doc ยง44.2), so a non-staff
message may have been authored by the ticket's owner, another organization member, or
the current caller; there is no single name that is correct for all of them.
One ticket visible to the caller, in the detail view (paired with its messages in
TicketDetailResponse) -- any ticket belonging to any organization the caller may see
(design doc ยง44.2), not only ones the caller personally opened. Same no-staff-identity
rule as TicketSummaryResponse.
List A Ticket's Support Access Requests
Every access request support has raised on this ticket, in every state, so the customer can see what was asked for, what they agreed to, and what has since expired or been taken back. A pending entry is a decision waiting on the caller: support holding only the ticket-management permission sees nothing of a service until one of these is approved. The requesting agent is deliberately not named -- this surface renders support as one team throughout. Each entry carries max_duration_hours, the longest an approval can last, and expires_at once it has been given.
path Parameters
idList A Ticket's Support Access Requests โบ Responses
Access requests on this ticket, including answered ones.
chat_message_idThe message in the ticket thread that carries this prompt.
created_atidmax_duration_hoursThe longest an approval can last, from the domain's MAX_GRANT_DURATION.
Served on every request, pending ones included, because that is when the
customer needs it: it is the "for how long" half of what they are being
asked to agree to. Closing the ticket ends the grant sooner.
service_instance_idThe one service this request would reach. A grant is scoped to a service instance and to nothing else -- not the subscription, not the organization, not the customer's other services.
stateWhere one request sits in its lifecycle.
Five states and not four: Denied, Revoked and Expired are three
different stories about why access is not live, and collapsing them would
make the audit trail the customer reads disagree with the one the platform
keeps. Mirrors the domain enum's own snake_case wire form
(SupportAccessRequestState), which is persisted, so the two cannot drift.
ticket_idexpires_atWhen an approved grant stops working. Null until approval, and null forever on a request that was denied.
responded_atWhen the request was approved, denied or revoked. Null while pending.
Approve A Support Access Request
Gives the requesting agent access to the one service instance named on the request, and to nothing else the caller owns. The grant starts now and stops on its own: it expires after max_duration_hours, it is revoked the moment the ticket is closed, and the caller can withdraw it at any time with DELETE /support/access-requests/{request_id}. Every request made with the grant is audited, and the approval itself is recorded against the organization with the approving user on it. Requires an Owner or Admin membership of the organization that owns the service (or its personal owner): a member who can see the ticket cannot hand over the infrastructure. Answers 422 if the request has already been answered or its ticket has since closed. An access request the caller may not answer answers 404 with exactly the body an invented id gets: whether a request exists, and whether it is still pending, is not something this surface reports to somebody with no business knowing.
path Parameters
request_idApprove A Support Access Request โบ Responses
The approved request, now carrying expires_at.
chat_message_idThe message in the ticket thread that carries this prompt.
created_atidmax_duration_hoursThe longest an approval can last, from the domain's MAX_GRANT_DURATION.
Served on every request, pending ones included, because that is when the
customer needs it: it is the "for how long" half of what they are being
asked to agree to. Closing the ticket ends the grant sooner.
service_instance_idThe one service this request would reach. A grant is scoped to a service instance and to nothing else -- not the subscription, not the organization, not the customer's other services.
stateWhere one request sits in its lifecycle.
Five states and not four: Denied, Revoked and Expired are three
different stories about why access is not live, and collapsing them would
make the audit trail the customer reads disagree with the one the platform
keeps. Mirrors the domain enum's own snake_case wire form
(SupportAccessRequestState), which is persisted, so the two cannot drift.
ticket_idexpires_atWhen an approved grant stops working. Null until approval, and null forever on a request that was denied.
responded_atWhen the request was approved, denied or revoked. Null while pending.
Deny A Support Access Request
Refuses the request. Nothing is granted, no expires_at is written, and the agent keeps seeing exactly what they saw before: nothing of the service. The ticket stays open and the conversation continues -- denying access is not closing the conversation, and support can still be helped with what the customer chooses to describe. Same authority and same refusals as the approval it replaces; the two answer identically to anybody who may not decide. An access request the caller may not answer answers 404 with exactly the body an invented id gets: whether a request exists, and whether it is still pending, is not something this surface reports to somebody with no business knowing.
path Parameters
request_idDeny A Support Access Request โบ Responses
The denied request.
chat_message_idThe message in the ticket thread that carries this prompt.
created_atidmax_duration_hoursThe longest an approval can last, from the domain's MAX_GRANT_DURATION.
Served on every request, pending ones included, because that is when the
customer needs it: it is the "for how long" half of what they are being
asked to agree to. Closing the ticket ends the grant sooner.
service_instance_idThe one service this request would reach. A grant is scoped to a service instance and to nothing else -- not the subscription, not the organization, not the customer's other services.
stateWhere one request sits in its lifecycle.
Five states and not four: Denied, Revoked and Expired are three
different stories about why access is not live, and collapsing them would
make the audit trail the customer reads disagree with the one the platform
keeps. Mirrors the domain enum's own snake_case wire form
(SupportAccessRequestState), which is persisted, so the two cannot drift.
ticket_idexpires_atWhen an approved grant stops working. Null until approval, and null forever on a request that was denied.
responded_atWhen the request was approved, denied or revoked. Null while pending.
Withdraw A Support Access Grant
Takes back a grant the caller gave, or refuses one still pending, in one call. It takes effect on the agent's very next request: a grant is re-read from storage on every authenticated call rather than carried in the agent's token, so there is no session to wait out and nothing cached to catch up. The request row survives as revoked so the audit trail can say consent was withdrawn rather than never given. Same Owner/Admin authority as approving. An access request the caller may not answer answers 404 with exactly the body an invented id gets: whether a request exists, and whether it is still pending, is not something this surface reports to somebody with no business knowing.
path Parameters
request_idWithdraw A Support Access Grant โบ Responses
The request, now revoked.
chat_message_idThe message in the ticket thread that carries this prompt.
created_atidmax_duration_hoursThe longest an approval can last, from the domain's MAX_GRANT_DURATION.
Served on every request, pending ones included, because that is when the
customer needs it: it is the "for how long" half of what they are being
asked to agree to. Closing the ticket ends the grant sooner.
service_instance_idThe one service this request would reach. A grant is scoped to a service instance and to nothing else -- not the subscription, not the organization, not the customer's other services.
stateWhere one request sits in its lifecycle.
Five states and not four: Denied, Revoked and Expired are three
different stories about why access is not live, and collapsing them would
make the audit trail the customer reads disagree with the one the platform
keeps. Mirrors the domain enum's own snake_case wire form
(SupportAccessRequestState), which is persisted, so the two cannot drift.
ticket_idexpires_atWhen an approved grant stops working. Null until approval, and null forever on a request that was denied.
responded_atWhen the request was approved, denied or revoked. Null while pending.
List Live Support Access Grants
Every grant support currently holds on a service belonging to an organization the caller is a member of: approved, not yet expired, and on a ticket that is still open. This is the "is anybody inside my infrastructure right now" read, so it deliberately answers only live grants -- a denied, revoked or expired one is history and belongs on its own ticket. Each entry names the service instance and when the grant lapses; any of them can be withdrawn immediately with DELETE /support/access-requests/{request_id}.
List Live Support Access Grants โบ Responses
Live grants across every organization the caller belongs to.
chat_message_idThe message in the ticket thread that carries this prompt.
created_atidmax_duration_hoursThe longest an approval can last, from the domain's MAX_GRANT_DURATION.
Served on every request, pending ones included, because that is when the
customer needs it: it is the "for how long" half of what they are being
asked to agree to. Closing the ticket ends the grant sooner.
service_instance_idThe one service this request would reach. A grant is scoped to a service instance and to nothing else -- not the subscription, not the organization, not the customer's other services.
stateWhere one request sits in its lifecycle.
Five states and not four: Denied, Revoked and Expired are three
different stories about why access is not live, and collapsing them would
make the audit trail the customer reads disagree with the one the platform
keeps. Mirrors the domain enum's own snake_case wire form
(SupportAccessRequestState), which is persisted, so the two cannot drift.
ticket_idexpires_atWhen an approved grant stops working. Null until approval, and null forever on a request that was denied.
responded_atWhen the request was approved, denied or revoked. Null while pending.
Create My Support Ticket
Opens a new support ticket owned by the caller, in the caller's own personal organization, with the given subject and opening message.
The organization is never named: it is always the caller's own personal organization, resolved from their token, so there is no organization a caller could get wrong and no 404 for one they do not belong to. Opening a ticket under a shared organization needs POST /api/v2/organizations/{id}/tickets instead; both routes create the same ticket through the same service and differ only in how the organization is named.
The new ticket is returned together with its opening message, so a composer does not have to re-read the ticket to render what it just sent.
Create My Support Ticket โบ Request Body
bodysubjectCreate My Support Ticket โบ Responses
The new ticket
One message in TicketDetailResponse::messages.
Soft-deleted messages never appear here at all -- this endpoint reads
SupportMessageRepository::find_by_ticket_id_visible, which already
excludes them, rather than /v1's find_by_ticket_id (every message,
with the body blanked for a deleted one at render time). A /v2 read
surface has no reason to reconstruct a "[deleted]" placeholder in the
thread; see this crate's report.
A staff author's identity is never exposed: author_is_staff is true
and author_name reads "Support Team". A non-staff message's author_name is that
message's own author's display name, resolved per message by the caller of
from_message -- not necessarily the caller of the request. A ticket's whole thread is
now visible to every member of its organization (design doc ยง44.2), so a non-staff
message may have been authored by the ticket's owner, another organization member, or
the current caller; there is no single name that is correct for all of them.
One ticket visible to the caller, in the detail view (paired with its messages in
TicketDetailResponse) -- any ticket belonging to any organization the caller may see
(design doc ยง44.2), not only ones the caller personally opened. Same no-staff-identity
rule as TicketSummaryResponse.
Signal Typing on a Support Ticket
Broadcasts a transient "typing" frame to everyone holding this ticket's event stream open. Stores nothing and has no effect other than that frame, so a client may call it as a throttled heartbeat while the composer has focus. A ticket in an organization the caller does not belong to answers 404, the same as an invented id. A real ticket the caller may read but does not own answers 403: the services behind this surface still authorize the ticket's own owner and nobody else (ZC-0424), which is narrower than the shared-workspace read model GET /support/tickets/{id} uses.
path Parameters
idSignal Typing on a Support Ticket โบ Responses
Typing signalled
Mark a Support Ticket Read
Moves the customer side's read cursor to now, which is what clears the ticket's unread_count in the listing and what the other side sees as a read frame on the event stream. Idempotent. A ticket in an organization the caller does not belong to answers 404, the same as an invented id. A real ticket the caller may read but does not own answers 403: the services behind this surface still authorize the ticket's own owner and nobody else (ZC-0424), which is narrower than the shared-workspace read model GET /support/tickets/{id} uses.
path Parameters
idMark a Support Ticket Read โบ Responses
Read cursor moved
Delete a Support Message
Retracts a message the caller wrote themselves. Only the original author may delete. The row is kept and marked deleted rather than removed, but GET /support/tickets/{id} omits it entirely rather than rendering a placeholder, so the thread simply no longer contains it. Deleting an already deleted message succeeds. A ticket in an organization the caller does not belong to answers 404, the same as an invented id. A real ticket the caller may read but does not own answers 403: the services behind this surface still authorize the ticket's own owner and nobody else (ZC-0424), which is narrower than the shared-workspace read model GET /support/tickets/{id} uses.
path Parameters
idmessage_idDelete a Support Message โบ Responses
Message deleted
Edit a Support Message
Rewrites the body of a message the caller wrote themselves. Only the original author may edit, and only while the ticket is open; a closed ticket answers a business-rule error rather than a permission one, because reopening it would make the same call succeed. The superseded body is kept and readable through .../edits, so an edit is never a silent rewrite. A ticket in an organization the caller does not belong to answers 404, the same as an invented id. A real ticket the caller may read but does not own answers 403: the services behind this surface still authorize the ticket's own owner and nobody else (ZC-0424), which is narrower than the shared-workspace read model GET /support/tickets/{id} uses.
path Parameters
idmessage_idEdit a Support Message โบ Request Body
bodyEdit a Support Message โบ Responses
The edited message
author_is_callerWhether the caller wrote this message.
ZC-1110. A ticket thread is visible to the whole organization
(design doc 44.2), so author_name alone cannot answer this: two
colleagues can share a display name, and offering edit or retract on
the wrong bubble is worse than offering neither.
Stated as a boolean rather than by publishing the author's id on
purpose. The id would answer this question and one nobody asked --
which member of staff wrote a staff reply -- and this module
deliberately shows staff only as "Support Team". A boolean cannot leak
that however it is later refactored; an Option<Uuid> could, by being
populated in one branch somebody forgot to guard. It is always false
for a staff message.
author_is_staffauthor_namebodycan_editWhether the caller may correct this message right now.
The server's own rule, not a hint to re-derive. EditSupportMessage
refuses a non-author with Forbidden and refuses ANY edit on a closed
ticket with TicketClosed, so this is both conditions already
resolved. A client computing author_is_caller && !closed for itself
would be deciding an entitlement, which is the boundary every frontend
in this program is told not to cross, and it would silently drift the
day kernel adds a third condition.
can_retractWhether the caller may retract this message right now.
Separate from can_edit because the rules genuinely
differ: retraction stays available on a CLOSED ticket, where editing
does not. Somebody who posted something they regret can always take it
back; rewriting history after the conversation ended is what the
closed-ticket rule exists to stop. Collapsing these into one flag
would quietly pick one of those two behaviours for both.
created_atidkindauthor_avatar_asset_idThe customer author's avatar (V-0880), as an asset id the client
resolves through the asset route. None for every staff message
(staff are shown only as "Support Team") and for a customer with no
avatar; the client falls back to a monogram.
edited_atList a Support Message's Edits
Prior versions of one customer-side message in a ticket the caller may see, oldest first, so an edited message can show what it replaced. A staff message's edit history is not served here and answers 404 identically to a message that belongs to another ticket: which words an agent retracted is a support-side record, and this surface never identifies the agent behind "Support Team". A ticket in an organization the caller does not belong to answers 404 as well.
path Parameters
idmessage_idList a Support Message's Edits โบ Responses
Superseded versions, oldest first
edited_atidprevious_bodyUpload a Support Attachment
Stores one file against this ticket ahead of the message that will carry it, so a composer can show per-file progress and let the sender drop a file before committing to the message. Send it as multipart/form-data with the file in a part named file; the part's filename and content type are recorded as sent, and anything after the first part is ignored. Answers exactly the four fields TicketMessageAttachmentRequest wants, so the response goes straight into the attachments array of POST .../messages when the message is finally sent -- none of it is trusted there, the asset row is re-read. A file uploaded and never sent stays unreferenced and renders nowhere; removing one before sending is DELETE .../attachments/{asset_id}. The ceiling is 50 MiB, enforced while the body is being read rather than after it is buffered, and a larger upload answers 413. A ticket in an organization the caller does not belong to answers 404, the same as an invented id. A real ticket the caller may read but does not own answers 403: the services behind this surface still authorize the ticket's own owner and nobody else (ZC-0424), which is narrower than the shared-workspace read model GET /support/tickets/{id} uses.
path Parameters
idUpload a Support Attachment โบ Responses
The stored attachment, ready to be sent
asset_idmime_typeoriginal_filenamesize_bytesDiscard an Unsent Support Attachment
Deletes a file uploaded against this ticket that no message carries yet, both the stored object and its asset row. An attachment a message already references answers a field error instead and is left alone: it is part of the conversation at that point, and removing it is a matter of retracting the message. An asset id that names nothing, or names something that is not an unsent attachment of this ticket, answers 404 indistinguishably. A ticket in an organization the caller does not belong to answers 404, the same as an invented id. A real ticket the caller may read but does not own answers 403: the services behind this surface still authorize the ticket's own owner and nobody else (ZC-0424), which is narrower than the shared-workspace read model GET /support/tickets/{id} uses.
path Parameters
asset_ididDiscard an Unsent Support Attachment โบ Responses
Attachment discarded
Stream Support Ticket Events
Holds a text/event-stream open for one ticket the caller may see, so the conversation updates without polling. Answers 404 for a ticket in an organization the caller does not belong to, exactly as reading that ticket would. Every frame is one TicketEventResponse object, addressed by the SSE event: name (message_sent, message_edited, message_deleted, typing, read, claim_changed, closed, review_submitted). Frames carry ids, never message bodies, and never identify a staff actor: re-read the ticket to render what changed. The server closes the connection after fifteen minutes and the first frame sets the reconnect interval, so a browser EventSource re-opens it by itself; the caller's access to the ticket is also re-checked periodically while the connection is held.
path Parameters
idStream Support Ticket Events โบ Responses
An open event stream. The schema describes one frame's data: payload, not a single JSON response body.
Decision Table
| Variant | Matching Criteria |
|---|---|
| type = object ยท requires: type, ticketId, messageId +1 more | |
| type = object ยท requires: type, ticketId, messageId +1 more | |
| type = object ยท requires: type, ticketId, messageId +1 more | |
| type = object ยท requires: type, ticketId, isStaff | |
| type = object ยท requires: type, ticketId, isStaff +1 more | |
| type = object ยท requires: type, ticketId, claimed | |
| type = object ยท requires: type, ticketId, isStaff | |
| type = object ยท requires: type, ticketId |
typeisStaffmessageIdticketIdauthorId