Skip to content

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 ​

FieldValuesMeaning
typecustomer_request, refund_requestA conversation with a customer, or a staff-only refund decision record
sourcecustomer, staffWho opened the ticket
statusopen, in_progress, resolved, closedCustomer-request lifecycle. Refund requests close when decided
prioritylow, medium, high, urgentStored priority; new requests default to medium
markeroverdue, awaiting_responseQueue 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:

ParameterTypeDefaultDescription
cursorstringnoneOpaque `createdAt
limitinteger201 to 100

Response 200:

json
{
  "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.

json
{
  "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.

json
{
  "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:

json
{
  "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.

QueryTypeDescription
cursorstringOpaque `createdAt
limitinteger1 to 100, default 20
statusenumopen, in_progress, resolved, closed
typeenumcustomer_request, refund_request
markerenumoverdue or awaiting_response
assigneeIdUUID or unassignedFilter by assigned officer or no assignee

Each item contains the Ticket fields plus:

json
{
  "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:

json
{
  "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:

json
{ "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:

CurrentAllowed targets
openin_progress, resolved, closed
in_progressresolved, closed
resolvedin_progress, closed
closednone

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:

json
[{ "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:

json
{
  "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:

json
{ "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 }:

EventAudienceDedupe key
ticket.openedActive users with role supportticket.opened:<ticketId>
ticket.assignedThe selected assigneeticket.assigned:<auditId>
ticket.repliedThe customer for a public staff replyticket.replied:<commentId>
ticket.resolvedThe customerticket.resolved:<auditId>
ticket.refund_requestedActive users holding the refund decider roleticket.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.

CodeStatusMeaning
TICKET_NOT_FOUND404Ticket is missing, outside the caller's scope, or is a refund request on a customer route
TICKET_CLOSED409Closed ticket cannot receive comments or replies or change status
TICKET_LINK_INVALID422Linked record is missing, not owned by the customer, or a required refund link is invalid
TICKET_ASSIGNEE_INVALID422Assignee is not an active activation officer
TICKET_ACTION_NOT_ALLOWED409Operation is invalid for this type, scope, status, or transition
REFUND_ORDER_NOT_PAID422Linked order is not paid, activating, or completed
REFUND_NOT_DECIDABLE409Approval was requested while the linked activation is not failed or cancelled
REFUND_ALREADY_DECIDED409Decision already exists
REFUND_SELF_DECISION403Request opener attempted to decide it
REFUND_DECIDER_CANNOT_OPEN403Caller holds the refund decider role, which never opens a refund request
REFUND_STORE_APPROVAL_DEFERRED409Store-linked approval is intentionally unavailable
OFFICER_COMPANY_READ_ONLY409Refund request on a company activation, whose funds already return to the company
AUTH_REQUIRED401Missing or invalid authentication
AUTH_FORBIDDEN403Missing required role or support area level
VALIDATION_ERROR400Invalid UUID, field, link combination, cursor, limit, or enum

Internal documentation - Activation System