Zaroz
Zaroz Cloud Kernel Open API

List My Support Tickets

GET
/api/v2/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.

List My Support Tickets โ€บ query Parameters

page
โ€‹integerย ยทย uint32ย ยทย min: 0ย ยทย style: form

Page number (1-indexed). Defaults to 1.

Page number (1-indexed). Defaults to 1.

Default: 1
page_size
โ€‹integerย ยทย uint32ย ยทย min: 0ย ยทย style: form

Number of items per page. Defaults to 20, max 100.

Number of items per page. Defaults to 20, max 100.

Default: 20

List My Support Tickets โ€บ Responses

Tickets

โ€‹TicketSummaryResponse[]ย ยทย required
โ€‹PaginationMetaย ยทย required

Pagination metadata included in paginated responses


Get My Support Ticket

GET
/api/v2/support/tickets/{id}

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").

Get My Support Ticket โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Get My Support Ticket โ€บ Responses

The ticket

โ€‹TicketMessageResponse[]ย ยทย required
โ€‹TicketResponseย ยทย required

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

POST
/api/v2/support/tickets/{id}/messages

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.

Reply to Support Ticket โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Reply to Support Ticket โ€บ Request Body

Body for `POST /api/v2/support/tickets/{id}/messages`.
body
โ€‹stringย ยทย required

Files 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

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.
โ€‹TicketMessageAttachmentResponse[]ย ยทย required
author_is_caller
โ€‹booleanย ยทย required

Whether 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_staff
โ€‹booleanย ยทย required
author_name
โ€‹stringย ยทย required
body
โ€‹stringย ยทย required
can_edit
โ€‹booleanย ยทย required

Whether 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_retract
โ€‹booleanย ยทย required

Whether 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_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
kind
โ€‹TicketMessageKindResponseย ยทย enumย ยทย required
Enum values:
text
access_request
author_avatar_asset_id
โ€‹string | nullย ยทย uuid

The 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_at
โ€‹string | nullย ยทย date-time

Submit Support Ticket Review

POST
/api/v2/support/tickets/{id}/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.

Submit Support Ticket Review โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Submit Support Ticket Review โ€บ Request Body

Body for `POST /api/v2/support/tickets/{id}/review`. `deny_unknown_fields` (`ZC-0752`'s convention): a caller that misspells `response_quality` or `resolution_speed` is told, rather than having the field silently dropped and the request still succeed with a rating nobody chose. `response_quality` and `resolution_speed` are validated against `1..=5` by `SupportTicketReview::create` (`crates/domain/model/src/domain/support/ review.rs`), not here: this struct only shapes the wire, the domain type owns the invariant.
problem_solved
โ€‹booleanย ยทย required
resolution_speed
โ€‹integerย ยทย uint8ย ยทย min: 0ย ยทย max: 255ย ยทย required
response_quality
โ€‹integerย ยทย uint8ย ยทย min: 0ย ยทย max: 255ย ยทย required
comment
โ€‹string | null
Default: null

Submit Support Ticket Review โ€บ Responses

The submitted review

The customer's own CSAT review of a closed ticket, echoed back once submitted. `submitted_by_id` is deliberately omitted: it is always the caller here by construction (the service refuses anyone else -- see `submit_review_docs`), so echoing it back would only repeat what the caller already knows about themselves.
problem_solved
โ€‹booleanย ยทย required
resolution_speed
โ€‹integerย ยทย uint8ย ยทย min: 0ย ยทย max: 255ย ยทย required
response_quality
โ€‹integerย ยทย uint8ย ยทย min: 0ย ยทย max: 255ย ยทย required
submitted_at
โ€‹stringย ยทย date-timeย ยทย required
ticket_id
โ€‹stringย ยทย uuidย ยทย required
comment
โ€‹string | null

List Organization Support Tickets

GET
/api/v2/organizations/{id}/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.

List Organization Support Tickets โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

List Organization Support Tickets โ€บ query Parameters

page
โ€‹integerย ยทย uint32ย ยทย min: 0ย ยทย style: form

Page number (1-indexed). Defaults to 1.

Page number (1-indexed). Defaults to 1.

Default: 1
page_size
โ€‹integerย ยทย uint32ย ยทย min: 0ย ยทย style: form

Number of items per page. Defaults to 20, max 100.

Number of items per page. Defaults to 20, max 100.

Default: 20

List Organization Support Tickets โ€บ Responses

Tickets

โ€‹TicketSummaryResponse[]ย ยทย required
โ€‹PaginationMetaย ยทย required

Pagination metadata included in paginated responses


Create Support Ticket

POST
/api/v2/organizations/{id}/tickets

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.

Create Support Ticket โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Create Support Ticket โ€บ Request Body

Body for `POST /api/v2/organizations/{id}/tickets`.
body
โ€‹stringย ยทย required
subject
โ€‹stringย ยทย required

Create Support Ticket โ€บ Responses

The new ticket

A freshly opened ticket together with its opening message. Returned by `createTicket` rather than the bare `TicketResponse`: the caller sent a message body along with the subject, and it would otherwise disappear from the response until the next `getMyTicket` read.
โ€‹TicketMessageResponseย ยทย required

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.

โ€‹TicketResponseย ยทย required

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

GET
/api/v2/support/tickets/{id}/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.

List A Ticket's Support Access Requests โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

List A Ticket's Support Access Requests โ€บ Responses

Access requests on this ticket, including answered ones.

One access request as the CUSTOMER sees it. Names no agent, for the reason this module's doc comment gives. It names the service instance instead, which is the thing being consented to, and the chat message the prompt was posted as, so a client can anchor the approve/deny buttons to the right bubble in the thread rather than to a separate inbox the customer would have to go looking for.
chat_message_id
โ€‹stringย ยทย uuidย ยทย required

The message in the ticket thread that carries this prompt.

created_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
max_duration_hours
โ€‹integerย ยทย int64ย ยทย required

The 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_id
โ€‹stringย ยทย uuidย ยทย required

The 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.

state
โ€‹SupportAccessRequestStateResponseย ยทย enumย ยทย required

Where 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.

Enum values:
pending
approved
denied
revoked
expired
ticket_id
โ€‹stringย ยทย uuidย ยทย required
expires_at
โ€‹string | nullย ยทย date-time

When an approved grant stops working. Null until approval, and null forever on a request that was denied.

responded_at
โ€‹string | nullย ยทย date-time

When the request was approved, denied or revoked. Null while pending.


Approve A Support Access Request

POST
/api/v2/support/access-requests/{request_id}/approve

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.

Approve A Support Access Request โ€บ path Parameters

request_id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Approve A Support Access Request โ€บ Responses

The approved request, now carrying expires_at.

One access request as the CUSTOMER sees it. Names no agent, for the reason this module's doc comment gives. It names the service instance instead, which is the thing being consented to, and the chat message the prompt was posted as, so a client can anchor the approve/deny buttons to the right bubble in the thread rather than to a separate inbox the customer would have to go looking for.
chat_message_id
โ€‹stringย ยทย uuidย ยทย required

The message in the ticket thread that carries this prompt.

created_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
max_duration_hours
โ€‹integerย ยทย int64ย ยทย required

The 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_id
โ€‹stringย ยทย uuidย ยทย required

The 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.

state
โ€‹SupportAccessRequestStateResponseย ยทย enumย ยทย required

Where 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.

Enum values:
pending
approved
denied
revoked
expired
ticket_id
โ€‹stringย ยทย uuidย ยทย required
expires_at
โ€‹string | nullย ยทย date-time

When an approved grant stops working. Null until approval, and null forever on a request that was denied.

responded_at
โ€‹string | nullย ยทย date-time

When the request was approved, denied or revoked. Null while pending.


Deny A Support Access Request

POST
/api/v2/support/access-requests/{request_id}/deny

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.

Deny A Support Access Request โ€บ path Parameters

request_id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Deny A Support Access Request โ€บ Responses

The denied request.

One access request as the CUSTOMER sees it. Names no agent, for the reason this module's doc comment gives. It names the service instance instead, which is the thing being consented to, and the chat message the prompt was posted as, so a client can anchor the approve/deny buttons to the right bubble in the thread rather than to a separate inbox the customer would have to go looking for.
chat_message_id
โ€‹stringย ยทย uuidย ยทย required

The message in the ticket thread that carries this prompt.

created_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
max_duration_hours
โ€‹integerย ยทย int64ย ยทย required

The 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_id
โ€‹stringย ยทย uuidย ยทย required

The 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.

state
โ€‹SupportAccessRequestStateResponseย ยทย enumย ยทย required

Where 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.

Enum values:
pending
approved
denied
revoked
expired
ticket_id
โ€‹stringย ยทย uuidย ยทย required
expires_at
โ€‹string | nullย ยทย date-time

When an approved grant stops working. Null until approval, and null forever on a request that was denied.

responded_at
โ€‹string | nullย ยทย date-time

When the request was approved, denied or revoked. Null while pending.


Withdraw A Support Access Grant

DELETE
/api/v2/support/access-requests/{request_id}

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.

Withdraw A Support Access Grant โ€บ path Parameters

request_id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Withdraw A Support Access Grant โ€บ Responses

The request, now revoked.

One access request as the CUSTOMER sees it. Names no agent, for the reason this module's doc comment gives. It names the service instance instead, which is the thing being consented to, and the chat message the prompt was posted as, so a client can anchor the approve/deny buttons to the right bubble in the thread rather than to a separate inbox the customer would have to go looking for.
chat_message_id
โ€‹stringย ยทย uuidย ยทย required

The message in the ticket thread that carries this prompt.

created_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
max_duration_hours
โ€‹integerย ยทย int64ย ยทย required

The 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_id
โ€‹stringย ยทย uuidย ยทย required

The 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.

state
โ€‹SupportAccessRequestStateResponseย ยทย enumย ยทย required

Where 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.

Enum values:
pending
approved
denied
revoked
expired
ticket_id
โ€‹stringย ยทย uuidย ยทย required
expires_at
โ€‹string | nullย ยทย date-time

When an approved grant stops working. Null until approval, and null forever on a request that was denied.

responded_at
โ€‹string | nullย ยทย date-time

When the request was approved, denied or revoked. Null while pending.


List Live Support Access Grants

GET
/api/v2/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.

One access request as the CUSTOMER sees it. Names no agent, for the reason this module's doc comment gives. It names the service instance instead, which is the thing being consented to, and the chat message the prompt was posted as, so a client can anchor the approve/deny buttons to the right bubble in the thread rather than to a separate inbox the customer would have to go looking for.
chat_message_id
โ€‹stringย ยทย uuidย ยทย required

The message in the ticket thread that carries this prompt.

created_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
max_duration_hours
โ€‹integerย ยทย int64ย ยทย required

The 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_id
โ€‹stringย ยทย uuidย ยทย required

The 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.

state
โ€‹SupportAccessRequestStateResponseย ยทย enumย ยทย required

Where 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.

Enum values:
pending
approved
denied
revoked
expired
ticket_id
โ€‹stringย ยทย uuidย ยทย required
expires_at
โ€‹string | nullย ยทย date-time

When an approved grant stops working. Null until approval, and null forever on a request that was denied.

responded_at
โ€‹string | nullย ยทย date-time

When the request was approved, denied or revoked. Null while pending.


Create My Support Ticket

POST
/api/v2/me/tickets

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

Body for `POST /api/v2/organizations/{id}/tickets`.
body
โ€‹stringย ยทย required
subject
โ€‹stringย ยทย required

Create My Support Ticket โ€บ Responses

The new ticket

A freshly opened ticket together with its opening message. Returned by `createTicket` rather than the bare `TicketResponse`: the caller sent a message body along with the subject, and it would otherwise disappear from the response until the next `getMyTicket` read.
โ€‹TicketMessageResponseย ยทย required

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.

โ€‹TicketResponseย ยทย required

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

POST
/api/v2/support/tickets/{id}/typing

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.

Signal Typing on a Support Ticket โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Signal Typing on a Support Ticket โ€บ Responses

Typing signalled

No data returned

Mark a Support Ticket Read

POST
/api/v2/support/tickets/{id}/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.

Mark a Support Ticket Read โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Mark a Support Ticket Read โ€บ Responses

Read cursor moved

No data returned

Delete a Support Message

DELETE
/api/v2/support/tickets/{id}/messages/{message_id}

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.

Delete a Support Message โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple
message_id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Delete a Support Message โ€บ Responses

Message deleted

No data returned

Edit a Support Message

PATCH
/api/v2/support/tickets/{id}/messages/{message_id}

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.

Edit a Support Message โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple
message_id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Edit a Support Message โ€บ Request Body

Body for `PATCH /api/v2/support/tickets/{id}/messages/{message_id}`.
body
โ€‹stringย ยทย required

Edit a Support Message โ€บ Responses

The edited message

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.
โ€‹TicketMessageAttachmentResponse[]ย ยทย required
author_is_caller
โ€‹booleanย ยทย required

Whether 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_staff
โ€‹booleanย ยทย required
author_name
โ€‹stringย ยทย required
body
โ€‹stringย ยทย required
can_edit
โ€‹booleanย ยทย required

Whether 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_retract
โ€‹booleanย ยทย required

Whether 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_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
kind
โ€‹TicketMessageKindResponseย ยทย enumย ยทย required
Enum values:
text
access_request
author_avatar_asset_id
โ€‹string | nullย ยทย uuid

The 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_at
โ€‹string | nullย ยทย date-time

List a Support Message's Edits

GET
/api/v2/support/tickets/{id}/messages/{message_id}/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.

List a Support Message's Edits โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple
message_id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

List a Support Message's Edits โ€บ Responses

Superseded versions, oldest first

One superseded version of a message, newest last.
edited_at
โ€‹stringย ยทย date-timeย ยทย required
id
โ€‹stringย ยทย uuidย ยทย required
previous_body
โ€‹stringย ยทย required

Upload a Support Attachment

POST
/api/v2/support/tickets/{id}/attachments

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.

Upload a Support Attachment โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Upload a Support Attachment โ€บ Request Body

file
โ€‹stringย ยทย binaryย ยทย required

Upload a Support Attachment โ€บ Responses

The stored attachment, ready to be sent

What `POST /api/v2/support/tickets/{id}/attachments` hands back. Exactly the shape `TicketMessageAttachmentRequest` expects, so a composer can hold the response and post it straight back when the message is sent.
asset_id
โ€‹stringย ยทย uuidย ยทย required
mime_type
โ€‹stringย ยทย required
original_filename
โ€‹stringย ยทย required
size_bytes
โ€‹integerย ยทย int64ย ยทย required

Discard an Unsent Support Attachment

DELETE
/api/v2/support/tickets/{id}/attachments/{asset_id}

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.

Discard an Unsent Support Attachment โ€บ path Parameters

asset_id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple
id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Discard an Unsent Support Attachment โ€บ Responses

Attachment discarded

No data returned

Stream Support Ticket Events

GET
/api/v2/support/tickets/{id}/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.

Stream Support Ticket Events โ€บ path Parameters

id
โ€‹stringย ยทย uuidย ยทย requiredย ยทย style: simple

Stream Support Ticket Events โ€บ Responses

An open event stream. The schema describes one frame's data: payload, not a single JSON response body.

oneOf
Exactly one variant must match.

Decision Table

VariantMatching 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
Properties for Variant 1:
A new message landed in the thread. Carries ids only. The body is deliberately absent: a client re-reads the ticket, which is the one path where staff authorship is rendered correctly, rather than patching a bubble in from a payload that would have to repeat that logic.
type
โ€‹stringย ยทย constย ยทย required
Const value: message_sent
isStaff
โ€‹booleanย ยทย required
messageId
โ€‹stringย ยทย uuidย ยทย required
ticketId
โ€‹stringย ยทย uuidย ยทย required
authorId
โ€‹string | nullย ยทย uuid