Support Tickets API
Support tickets are customer requests and staff-owned refund requests. Customer routes are mounted at /tickets; staff routes are mounted at /admin/support. The API derives customer and staff identity from the authenticated session.
Vocabulary and Rules
| Field | Values | Meaning |
|---|---|---|
type | customer_request, refund_request | A conversation with a customer, or a staff-only refund decision record |
source | customer, staff | Who opened the ticket |
status | open, in_progress, resolved, closed | Customer-request lifecycle. Refund requests close when decided |
priority | low, medium, high, urgent | Stored priority; new requests default to medium |
marker | overdue, awaiting_response | Queue filters calculated from ticket timestamps |
Ticket references are generated from the ticket UUID as TKT- followed by its last 12 lowercase UUID hex characters, for example TKT-0123456789ab. The reference is unique and is the public identifier used in notification parameters.
A customer request may link to at most one customer-owned activation or store order. The linked record must belong to the customer. A refund request must link to exactly one activation or store order. It is never a customer route object.
The full staff Ticket object contains id, reference, userId, type, source, nullable openedBy, subject, description, status, priority, nullable assignedTo, nullable activationId, nullable storeOrderId, nullable dueAt, nullable firstResponseAt, nullable resolvedAt, nullable closedAt, nullable refundReason, nullable decision, nullable decidedBy, nullable decisionReason, nullable decidedAt, createdAt, and updatedAt. Customer responses intentionally omit staff-only fields such as userId, type/source metadata, assignee, priority, opener and decision actor.
Customer requests are due five calendar days after opening. overdue means dueAt is in the past and the ticket has not been resolved. awaiting_response means the ticket is a customer request with no first staff response and is not closed. The two markers are independent; a ticket can have both.
The first non-internal staff comment sets firstResponseAt once and changes an open customer request to in_progress. A customer reply changes resolved back to in_progress and clears resolvedAt. A customer reply never changes open or in_progress. Closed tickets refuse customer replies and staff comments.
Customer comments expose the customer's own author id but identify every staff reply only as isStaff: true; staff author ids are not disclosed. Internal staff comments never appear in customer responses. Refund request comments must always be internal.
Customer Routes
All routes below require an authenticated end_user. Other roles are refused by the role guard. Ownership is always the authenticated user id, not a request field.
GET /tickets
Lists the caller's customer_request tickets, newest first, using keyset pagination.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | none | Opaque `createdAt |
limit | integer | 20 | 1 to 100 |
Response 200:
{
"items": [
{
"id": "11111111-1111-4111-8111-111111111111",
"reference": "TKT-111111111111",
"subject": "Activation did not finish",
"description": "The line is still pending.",
"status": "in_progress",
"activationId": "22222222-2222-4222-8222-222222222222",
"storeOrderId": null,
"dueAt": "2026-06-05T10:00:00.000Z",
"firstResponseAt": "2026-06-02T11:00:00.000Z",
"resolvedAt": null,
"closedAt": null,
"createdAt": "2026-06-01T10:00:00.000Z",
"updatedAt": "2026-06-02T11:00:00.000Z"
}
],
"nextCursor": null,
"hasMore": false
}POST /tickets
Opens a customer request. subject is trimmed and limited to 200 characters. description is trimmed and limited to 5,000 characters. activationId and storeOrderId are optional, but at most one may be supplied.
{
"subject": "Activation did not finish",
"description": "The line is still pending.",
"activationId": "22222222-2222-4222-8222-222222222222"
}Response 201 is the customer ticket shape above. The ticket is open, has source: customer, openedBy equal to the customer, and a due date five calendar days ahead. Opening emits ticket.opened to active support staff.
GET /tickets/:id
Returns one owned customer request and its non-internal comments in oldest-first order.
{
"id": "11111111-1111-4111-8111-111111111111",
"reference": "TKT-111111111111",
"subject": "Activation did not finish",
"description": "The line is still pending.",
"status": "in_progress",
"activationId": "22222222-2222-4222-8222-222222222222",
"storeOrderId": null,
"dueAt": "2026-06-05T10:00:00.000Z",
"firstResponseAt": "2026-06-02T11:00:00.000Z",
"resolvedAt": null,
"closedAt": null,
"createdAt": "2026-06-01T10:00:00.000Z",
"updatedAt": "2026-06-02T11:00:00.000Z",
"comments": [
{
"id": "33333333-3333-4333-8333-333333333333",
"authorId": null,
"isStaff": true,
"content": "We are checking the activation.",
"createdAt": "2026-06-02T11:00:00.000Z"
}
]
}POST /tickets/:id/comments
Adds a public customer comment. The body is { "content": "..." }, trimmed and limited to 5,000 characters. A reply on a resolved ticket reopens it as in_progress; a closed ticket refuses the request. The response is 201 with the customer comment shape. This route only records the comment and an audit entry; the ticket.replied notification is sent to the customer when staff post a public reply, not the other way around.
Staff Support Routes
Support routes use the support area. View routes require requireArea('support'); write routes require Manage. super_admin has the effective area. Staff responses include a masked customer phone, assignee information, all comments, audit history, and live linked-record state.
GET /admin/support/tickets/counts
View access. Returns counts across the whole ticket table, not the caller's queue:
{
"open": 4,
"inProgress": 7,
"overdue": 2,
"awaitingResponse": 3,
"awaitingDecision": 1
}awaitingDecision counts refund requests with no decision. awaitingResponse applies only to non-closed customer requests without firstResponseAt.
GET /admin/support/tickets
View access. Lists both ticket types, newest first, with keyset pagination.
| Query | Type | Description |
|---|---|---|
cursor | string | Opaque `createdAt |
limit | integer | 1 to 100, default 20 |
status | enum | open, in_progress, resolved, closed |
type | enum | customer_request, refund_request |
marker | enum | overdue or awaiting_response |
assigneeId | UUID or unassigned | Filter by assigned officer or no assignee |
Each item contains the Ticket fields plus:
{
"customer": { "id": "...", "name": "Ahmed", "phone": "+9665•••••67" },
"assignee": { "id": "...", "name": "Sara Officer" },
"overdue": false,
"awaitingResponse": true
}assignee is null when unassigned. The response is { items, nextCursor, hasMore }.
GET /admin/support/tickets/:id
View access. Returns one ticket with the staff item fields, full comment thread including internal notes, audit history, and a linked object. An activation link is { kind: 'activation', id, status, errorCode, orderStatus }; a store link is { kind: 'store_order', id, issuanceState, errorCode }; no link produces null. A comment's authorId is null once its author's account was deleted; the portal shows it as former staff.
GET /admin/support/tickets/:id/store-order-events
View access. Returns { events }, the issuance timeline of the store order linked to the ticket, oldest first, each event { id, fromState, toState, errorCode, source, note, createdAt }. Support reads every ticket, so the only condition is that the ticket exists (404 TICKET_NOT_FOUND otherwise). A ticket with no linked store order, or an order with no recorded change yet, returns an empty list. Read only, and no customer route returns it.
POST /admin/support/tickets
Manage access. Opens a customer request for an end_user selected by customerId. The body is:
{
"customerId": "44444444-4444-4444-8444-444444444444",
"subject": "Customer called about activation",
"description": "Customer needs a status update.",
"activationId": "22222222-2222-4222-8222-222222222222"
}The linked record must belong to that customer. The ticket has source: staff, openedBy equal to the acting staff user, and firstResponseAt set immediately because the staff opened it. Response 201 is the full Ticket shape.
POST /admin/support/tickets/:id/assign
Manage access. Body: { "assigneeId": "..." }. The assignee must be an active activation_officer. Only customer requests can be assigned; closed tickets and refund requests cannot. Assigning an open ticket moves it to in_progress. When the ticket is linked to an activation another officer holds the fallout claim on, the claim moves to the new assignee in the same transaction, audited on the activation as officer.claim_moved with the old and new officer and the ticket id. An unclaimed activation stays unclaimed. The response is 200 with the Ticket shape and emits ticket.assigned to the selected officer.
POST /admin/support/tickets/:id/comments
Manage access. Body:
{ "content": "Internal note or customer-visible reply", "isInternal": false }The first non-internal staff comment sets the first response time and moves an open customer request to in_progress. Refund request comments must set isInternal: true. Response 201 is a TicketComment with authorId, content, isInternal, and createdAt. Public customer-request replies emit ticket.replied to the ticket owner.
POST /admin/support/tickets/:id/status
Manage access. Body: { "status": "in_progress" | "resolved" | "closed" }. This route is for customer requests only. Allowed moves are:
| Current | Allowed targets |
|---|---|
open | in_progress, resolved, closed |
in_progress | resolved, closed |
resolved | in_progress, closed |
closed | none |
Resolving sets resolvedAt; reopening (in_progress) clears it. Closing sets closedAt, and stamps resolvedAt too when the ticket had never been resolved; otherwise it keeps the existing timestamp. Any move that sets resolvedAt for the first time — to resolved, or straight to closed from an unresolved ticket — emits ticket.resolved to the customer. Response 200 is the Ticket shape.
GET /admin/support/officers
View access. Returns active activation officers sorted by name and id:
[{ "id": "...", "name": "Sara Officer" }]POST /admin/support/refund-requests
Manage access. Opens a staff refund request against a paid activation or paid store order. The body requires exactly one link:
{
"activationId": "22222222-2222-4222-8222-222222222222",
"reason": "activation_failed",
"note": "The activation failed after payment."
}reason is one of service_not_delivered, activation_failed, duplicate_charge, customer_request, or other. note is optional and limited to 500 characters. A paid order is one with status paid, activating, or completed. A second live request for the same activation or store order creates no duplicate. It answers 201 with created: false and only the existing request's id and reference, because the caller may not be allowed to read that request. A rejected request no longer blocks a later request. An activation on a company order is refused with OFFICER_COMPANY_READ_ONLY, because a failed or cancelled company activation already returns the drawn funds to the company. The caller must not hold the refund decider role — refused as REFUND_DECIDER_CANNOT_OPEN before any read, so the rule that an opener cannot decide their own request never leaves one without a possible decider.
Response 201 is { "ticket": Ticket, "created": true } for a new request, or { "ticket": { "id", "reference" }, "created": false } when a live one already existed.
POST /admin/support/tickets/:id/decision
Manage access and super_admin role are both required. Body:
{ "decision": "approved", "reason": "The activation is terminally failed." }The decision is one-time, closes the refund ticket, records decidedBy, decisionReason, and decidedAt, and writes an audit action. The person who opened the request cannot decide it. Approval is allowed only for an activation whose status is failed or cancelled; store-linked approval is refused until the store lane can guarantee that approval cannot cause a re-purchase. Rejection does not require a failed or cancelled activation. Response 200 is the closed Ticket shape.
Audit and Notifications
Ticket actions are audited as ticket.opened, ticket.assigned, ticket.replied, ticket.status_changed, refund_request.opened, and ticket.decided. Audit values contain ids and status/decision changes, and ticket.decided also records the decision reason; internal comment text is not a notification parameter.
The five registered ticket events are in-app only, category system, and use strict parameters { ticketId, reference }:
| Event | Audience | Dedupe key |
|---|---|---|
ticket.opened | Active users with role support | ticket.opened:<ticketId> |
ticket.assigned | The selected assignee | ticket.assigned:<auditId> |
ticket.replied | The customer for a public staff reply | ticket.replied:<commentId> |
ticket.resolved | The customer | ticket.resolved:<auditId> |
ticket.refund_requested | Active users holding the refund decider role | ticket.refund_requested:<ticketId> |
ticket.refund_requested is sent when a refund request is created, not when an existing live request is returned instead. Its template renders only for an active user of the refund decider role and refuses any other recipient.
Notification failures are best-effort and do not roll back the ticket transaction. The templates load the ticket at render time and verify recipient scope.
Each in-app notice carries a link in actionUrl: ticket.opened and ticket.refund_requested link to /support/tickets/<ticketId> (support staff and the refund decider, respectively), ticket.assigned links to /officer/tickets/<ticketId> (the assigned officer), and ticket.replied and ticket.resolved link to /tickets/<ticketId> (the customer) — a mobile application route; until the application learns it, it falls back to its own inbox. The staff paths carry no locale segment; the admin portal adds the reader's locale and follows only links under /support/tickets/ and /officer/tickets/ as its own pages.
Errors
All errors use the standard localized { error: { code, message, details? } } envelope.
| Code | Status | Meaning |
|---|---|---|
TICKET_NOT_FOUND | 404 | Ticket is missing, outside the caller's scope, or is a refund request on a customer route |
TICKET_CLOSED | 409 | Closed ticket cannot receive comments or replies or change status |
TICKET_LINK_INVALID | 422 | Linked record is missing, not owned by the customer, or a required refund link is invalid |
TICKET_ASSIGNEE_INVALID | 422 | Assignee is not an active activation officer |
TICKET_ACTION_NOT_ALLOWED | 409 | Operation is invalid for this type, scope, status, or transition |
REFUND_ORDER_NOT_PAID | 422 | Linked order is not paid, activating, or completed |
REFUND_NOT_DECIDABLE | 409 | Approval was requested while the linked activation is not failed or cancelled |
REFUND_ALREADY_DECIDED | 409 | Decision already exists |
REFUND_SELF_DECISION | 403 | Request opener attempted to decide it |
REFUND_DECIDER_CANNOT_OPEN | 403 | Caller holds the refund decider role, which never opens a refund request |
REFUND_STORE_APPROVAL_DEFERRED | 409 | Store-linked approval is intentionally unavailable |
OFFICER_COMPANY_READ_ONLY | 409 | Refund request on a company activation, whose funds already return to the company |
AUTH_REQUIRED | 401 | Missing or invalid authentication |
AUTH_FORBIDDEN | 403 | Missing required role or support area level |
VALIDATION_ERROR | 400 | Invalid UUID, field, link combination, cursor, limit, or enum |